Skip to main content

How does a screen read and change server data?

A screen names what it wants as a request — a DTO from the project's shared package — and watches it. It changes something by sending a command. Everything between the two — caching, sharing one fetch between widgets, applying updates, retries, the signed-in account — is the client behind dw, and a screen writes none of it.

What requests and commands are on the wire is requests and updates and commands and idempotency. This page is the Flutter side.

You wantA screen writesThe read underneath
one object, an optional one, a listDwReadBuilder(dw.request(request), builder: …)dw.request(request): AsyncValue<T>, AsyncValue<T?>, AsyncValue<List<T>>
a feed that grows as it scrollsDwPagedListView(request: request, itemBuilder: …)dw.pages(request): AsyncValue<DwPagedData<T>>
numbered pages with a totalDwReadBuilder(dw.table(request), builder: …)dw.table(request): AsyncValue<DwTablePage<T>>
a chat-like window around a pointDwWindowListView(request: request, …)dw.window(request, anchor: ...): AsyncValue<DwWindowData<T>>
to change somethingdw.action((_) => <Feature>Commands.x(…))dw.command(command): DwCallResult<R>
a value once, outside any widget—await dw.client.fetch(request): DwCallResult<R>

A screen shows a read through one of the three widgets, and nothing else. Taking the AsyncValue of ref.watch(dw.request(…)) apart in a widget — .value, .when(, .hasError, a switch over AsyncError(…) — is what dart run dartway_cli:dartway check refuses (forbiddenRequestRead); a controller in a feature's logic/, or wiring in core/, may watch a read to derive its own state.

Reading: dw.request​

DwReadBuilder(
dw.request(const ListMyBookings()),
placeholder: PlaceholderObjects.listOf(PlaceholderObjects.booking, 4),
builder: (context, bookings) => BookingList(bookings),
)

The request kind decides the value: a DwSingleRequest<T> gives T (an absent object is a dw.notFound refusal), a DwMaybeRequest<T> gives T?, a DwListRequest<T> gives List<T>. dw.request does not take a paginated request — those have their own bindings below.

A request is its own cache key. Widgets watching equal requests share one provider, one fetch and one set of channel subscriptions; dw.request(...) returns the same provider for an equal request, so calling it inside build is the intended use. The flip side: a field that changes on every build — DateTime.now() in a request — is a new request every frame. The example's schedule reads "from the start of today" out of a provider that changes once a day for exactly that reason (example/dartway_example_flutter/lib/app/schedule/logic/today_controller.dart).

The value stays live on its own. When a command's answer or another user's change carries an object the request is interested in, it is applied to what is on screen — inserted, replaced, removed or re-fetched, as the request declares. How a request declares it is requests and updates and channels and realtime. The screen contains no refresh code:

// example/dartway_example_flutter/lib/app/schedule/logic/schedule_commands.dart
static Future<DwCallResult<SessionBooking>> book(ClubSession session) =>
dw.command(BookSession(sessionId: session.id));

// example/dartway_example_flutter/lib/app/schedule/widgets/session_card.dart
AppButton.primary(
l10n.book,
onTap: dw.action(
(_) => ScheduleCommands.book(session),
onSuccessNotification: l10n.youAreBooked,
),
)

The answer carries the booking and the session; both lists on screen take them before the command completes, and other devices get them on their channels.

A watched request is released DwClientOptions.releaseDelay (one second by default) after its last watcher leaves, keeping its subscriptions meanwhile — so a rebuilt screen, or one left and re-entered quickly, finds current data instead of refetching.

Errors are typed​

Every way a read ends short of data is an AsyncError whose error is one of four types:

ErrorMeans
DwRefusalExceptionThe server refused; .refusal is the DwCallRefusal to render. Also a request whose validate() failed (nothing was sent), and every request once the build is incompatible.
DwFailedExceptionThe server failed. .incidentId is what the operator finds it by; .call names the request.
DwNotAuthenticatedExceptionThe request needs a signed-in user, and there is none.
DwTimeoutExceptionNo answer for callTimeout and no data to show. The client keeps retrying; the value becomes data when the server answers.

An error branch sorts by type, never by message.

Refreshing stays data​

A request that is running again — a pull to refresh, an update that asked for a refetch, a reconnected socket — keeps its last value as AsyncData. The screen does not flash back to a skeleton, and a list does not lose its scroll position because the socket blinked.

Pull to refresh is the notifier's refetch(), which completes when answered — and the retry of DwReadBuilder's failed view is the same call:

onRefresh: () => ref.read(dw.request(const ListMyBookings()).notifier).refetch(),

Use refetch(), not ref.invalidate: the client keeps a request alive for the release delay after its provider is thrown away, so a rebuilt provider reattaches to the same failed state instead of asking again.

Feeds: dw.pages​

A DwPageRequest<T> is an offset feed. dw.pages accumulates its pages into one list, and a screen shows it as DwPagedListView. From example/dartway_example_flutter/lib/app/news/widgets/news_post_list.dart:

DwPagedListView<NewsPost>(
request: const ListNews(),
placeholder: PlaceholderObjects.newsPost,
placeholderCount: 5,
emptyBuilder: (context) => Center(child: AppText.body(context.l10n.noNewsYet)),
itemBuilder: (context, post) => NewsPostCard(post: post),
)

The next page is asked for as the end comes near, and the screen writes no trigger. The slot after the last loaded row is built lazily, like the rows — when it comes within the scroll view's cache extent — and building it asks for the next page. A first page too short to fill the screen fills it page by page; no scroll listener, pixel threshold or "more" button is written by hand. While a page loads the slot shows the app's readLoadingBuilder; after a failed page it shows a retry (edgeBuilder to draw your own, at a constant height) and asks nothing by itself. A page that came back with no rows while more follow is not an empty feed: the list keeps loading. The first answer is DwReadBuilder's: a skeleton of placeholder rows or the app's loading view, onRefused branches, the app's failed view with a retry. header scrolls with the rows.

Its own scroll view, or a sliver in yours. DwPagedListView is a CustomScrollView. A feed that is one part of a page scrolling as a whole — a profile above it — is DwPagedListView.sliver(…) inside the page's own CustomScrollView, and pages as the end of the feed comes into that scroll view's cache extent. Inside a Column or a ListView of the page's own it would not scroll by itself, and its end — always built — would page to the end at once.

Underneath, DwPagedData<T> carries items (every loaded object, updates applied), hasMore, loadingMore and loadMoreError — why the last loadMore did not load. A failed next page is not an error of the AsyncValue: the loaded items stay, and the error sits beside them for the slot to show. loadMore() is safe to call as often as you like: one request while one is in flight, none when there is no more. refetch() reloads from the top, as many rows as are loaded.

Tables: dw.table​

A DwTableRequest<T> has page and pageSize fields. Each page is its own request, so paging is watching another request, and the previous page is released like any other. From example/dartway_example_flutter/lib/admin/users/widgets/admin_users_table.dart:

return DwReadBuilder(
dw.table(request),
placeholder: DwTablePage(
PlaceholderObjects.listOf(PlaceholderObjects.profile, 4),
total: 4,
page: 1,
pageSize: request.pageSize,
),
builder: (context, page) => UsersPage(page), // rows, and a pager over page.page / page.pageCount
);

DwTablePage<T> carries items, total, page, pageSize and pageCount. A row on the page is updated in place when it changes. Nothing is inserted into a table page: an object that is not on it, or a deletion, reads the page again, because its rows and its total may have moved. A page below 1 is refused locally, without a round trip.

Windows: dw.window​

A DwWindowRequest<T, S, I> is a newest-first sequence read around a point — a chat, an activity log. dw.window(request, anchor: cursor) opens it around the row of cursor (a string from request.cursorOf(item) or DwWindowCursor.encode(sortValue, id)), or at the newest rows when anchor is null. Windows of one request opened at different anchors are different entries.

Window sort values may be int, String, DateTime or DwCalendarDay, followed by an int or String id to make the position total. A calendar-day cursor carries the canonical YYYY-MM-DD string and compares by civil date; it is not converted to a midnight instant.

final provider = dw.window(request, anchor: request.cursorOf(lastRead));
ref.watch(provider); // AsyncValue<DwWindowData<T>>
ref.read(provider.notifier).loadOlder();
ref.read(provider.notifier).loadNewer();

DwWindowData<T> carries items (newest first), hasOlder, hasNewer, loadingOlder, loadingNewer, loadError, and two counters a list needs:

  • unseenNewerCount — rows that arrived live past the newest loaded one while the window does not reach the newest rows; zero once it does;
  • prependedCount — how many rows the last change put before the previous first row.

A screen rarely reads these by hand: DwWindowListView is the list built on them.

Changing: dw.command​

final result = await dw.command(BookSession(sessionId: session.id));

The answer is a DwCallResult<R>, sealed:

ResultMeans
DwCallOk(:value)Done. Its updates are already applied to every watched request.
DwCallRefused(:refusal)The server said no — an answer for the user.
DwNotAuthenticated()It needs a signed-in user; the session is already dropped.
DwCallFailed(:incidentId)The server failed.

Inside dw.action there is nothing to unwrap: a result that is not DwCallOk is handled — a refusal shown through DwFlutterConfig.refusalText, a not-authenticated answer signing out, a failure reported. That is the one way to send a command; see actions and refusal texts. dw.command is called in a feature's logic/ — <feature>_commands.dart, or the <Thing>Controller of a flow — or, for app-wide wiring no button starts (a push token), in lib/core/; only there is a result read: a controller switches over it to move the flow on, a command function reads result.valueOrThrow to hand a value to the action's followUpIfMountedAction. A widget reads none, and nothing wraps a command in try/catch: dart run dartway_cli:dartway check fails all of it as forbiddenCommandCall.

Three things happen without code:

  • validation first. A command that is DwSelfValidating and does not validate answers its first refusal at once, and nothing is sent;
  • retries are safe. A command carries an idempotency key kept across its retries, so a retry after a lost answer is answered with the stored outcome instead of running twice. Network failures are retried until callTimeout (30 seconds by default); any answer is final;
  • a timeout is an unknown outcome. When no answer came in time, the future completes with a DwTimeoutException — the command may have run. Repeating the intent is a new command, which is why the timeout is generous.

The signed-in account​

ref.watch(dw.accountId); // int?, null when signed out

dw.accountId is known from the stored session right after start, before the server has answered — the example's router and profile gate decide by it (appRouterStateProvider in example/dartway_example_flutter/lib/core/router/app_router_state.dart). The server corrects it: a rejected token makes it null.

Every watched request belongs to an account. State is kept per signed-in account, and a request that follows "my" channel (DwLiveChannel.ofCaller) resolves it for the account signed in. So:

  • dw.signIn(session) adopts a session — the answer of DwVerifyCode — and stores it. When the account changes, every watched request is released and asked again for the new account; the value goes through loading and never shows the previous account's data;
  • dw.signOut() ends the local session at once — every watched request is asked again anonymously — and asks the server to revoke the key. A revocation that did not happen is reported to the error pipeline as DwSignOutException; the session on the device is over either way.
// example/dartway_example_flutter/lib/auth/logic/auth_controller.dart
if (result case DwCallOk(value: final session)) {
await dw.signIn(session);
}

A screen resets nothing on sign-in or sign-out. The requests it watches follow the account.

Hearing without reading: dw.listen​

Some screens need what is published, not what is stored: a badge "3 new posts" has its starting count from a request and only has to hear the posts that follow. Reading a page to subscribe would send rows nobody shows.

// The starting number is a request; the badge watches it as usual.
final state = ref.watch(dw.request(const GetMyFeedReaderState()));

// The posts that follow are only heard.
final subscription = dw.listen(const [DwLiveChannel(AcmeChannel.feed)]).listen((
object,
) {
if (object is FeedPost) newPosts.value++;
});
// …subscription.cancel() when the badge goes away

The channels are subscribed while the stream is listened to and released when it is cancelled, shared with any watched request on the same channel; a caller channel follows a switch of account. Objects arrive as published — data objects and DwDeletedObjects. What is published while the socket is down is not replayed: the exact number belongs to the request, which is read again after every reconnect.

Live status and reconnects​

final status = ref.watch(dw.liveStatus); // DwConnectionStatus

Calls are HTTP and work whatever this says. The status tells whether the data on screen follows the server:

DwConnectionStatusMeans
idleNo socket: nothing watched is live, nobody is signed in (every subscription requires an account), or the client is not started. Nothing is being missed.
connectingOpening or authenticating the socket.
connectedSubscriptions are made as they are needed.
disconnectedThe socket is down and the client waits before the next attempt. Data on screen is not live.
incompatibleThis build cannot talk to this server. Terminal — see update required.

example/dartway_example_flutter/lib/ui_kit/3_special/common/connection_status_indicator.dart renders it as a coloured dot, treating idle and connected alike.

When the socket comes back:

  • calls waiting out a retry backoff go at once;
  • subscriptions are made again for every watched request;
  • a request whose data was fetched before its subscription became active — and may have missed an update meanwhile — is read again, with the old value still on screen as data.

A subscription the server refused is tried again after a reconnect; one the server closed (access revoked) stays closed for that account.

Showing a read: DwReadBuilder​

DwReadBuilder takes any read — dw.request, dw.table, dw.pages, dw.window — and renders every answer it can give:

DwReadBuilder(
dw.request(GetCourse(courseId: id)),
placeholder: placeholderCourse,
onRefused: {
DwCoreRefusal.notFound: (context, _) => const CourseUnavailable(),
AcmeRefusal.courseClosed: (context, refusal) => CourseClosed(refusal),
},
builder: (context, course) => CourseView(course),
)
  • data is builder;
  • loading is builder over placeholder, drawn as a skeleton (Skeletonizer, or SliverSkeletonizer when the builder returned a sliver); without a placeholder it is the app's DwFlutterConfig.readLoadingBuilder — the right choice where drawing the builder over stand-in data would itself mislead (a card showing a stranger's identifiers for a moment);
  • a refusal whose code onRefused names is that branch — the "unavailable" screen of a thing that is gone or not yours, the "closed" screen of a thing with a rule of its own;
  • any other refusal, a failure, or a server out of reach is the app's DwFlutterConfig.readFailedBuilder, handed a retry that runs refetch(). A failure (not a refusal, which is an answer, and not an unreachable server, which the client keeps asking) goes to the error pipeline with DwErrorSource.asyncBuild — once per failure, however often the screen rebuilds over it;
  • not signed in renders nothing: the session ended under a screen on its way out, and the sign-in screen is the message.

The app says once how a read looks while it loads and when it fails. readLoadingBuilder and readFailedBuilder are required by DwFlutterCore; the skeleton passes the kit's AppProgressIndicator and LoadFailedMessage (lib/core/dw_core.dart), and every DwReadBuilder, DwPagedListView and DwWindowListView shows them. No screen spells its own spinner — CircularProgressIndicator outside ui_kit/ is forbiddenProgressIndicator.

Several reads nest. The inner builder stands in the outer one's builder; each loads, fails and retries on its own.

A box, not a sliver. DwReadBuilder's branches are box widgets: in a CustomScrollView's slivers it goes inside a SliverToBoxAdapter, and a feed there is DwPagedListView.sliver.

The body comes from a builder; the chrome from logic/. A value the screen's chrome needs whatever the read answers — a title, whether a button is enabled, a badge, whether to leave room for a bar — is a provider in the feature's logic/ answering a plain value with a fallback (a .select of the read inside it), and the widget watches that provider. A DwReadBuilder never stands in an app bar, and a badge's read never gates or skeletonises the page it sits on. From template/dartway_starter_flutter/lib/admin/user_card/:

// logic/user_card_name.dart
final userCardNameProvider = Provider.autoDispose.family<String?, int>(
(ref, profileId) => ref.watch(
dw.request(GetUserCard(profileId: profileId)).select((card) => card.value?.profile.displayName),
),
);

// admin_user_card_page.dart
AdminScaffold(
title: ref.watch(userCardNameProvider(id)) ?? l10n.adminUsers,
body: DwReadBuilder(dw.request(GetUserCard(profileId: id)), onRefused: {…}, builder: …),
)

A value derived from reads as an AsyncValue — a logic/ provider combining two reads into the body of a section — is shown with DwReadBuilder.derived(provider, retry: (ref) => …): the same branches, the provider's errors being the reads', and a retry that names the reads to ask again.

State beside the server's: hooks, or a controller​

What the server holds is watched, never copied. What is left is state of the screen itself, and it is held one way:

  • a widget's own — a text or scroll controller, a focus node, an animation, a timer, a subscription, a toggle — is a hook in a HookWidget/HookConsumerWidget (useTextEditingController, useFocusNode, useAnimationController, useEffect with its cleanup, useState); a prop the widget resyncs from is a useEffect keyed on it;
  • shared by several widgets, or a flow with logic — a sign-in in steps, a form with an async submit, the reply a chat composer is writing — is a Riverpod Notifier named <Thing>Controller in the feature's logic/, usually autoDispose and a family of what it is about (example/dartway_example_flutter/lib/app/chat/logic/chat_composing_controller.dart).

StatefulWidget, setState and a ChangeNotifier/ValueNotifier held as state fail dart run dartway_cli:dartway check anywhere in lib/ (forbiddenStateHolder). An API that needs a State or a Listenable of its own takes // dw:allow-stateful <reason> on the class, and every run lists it; the skeleton and the example have none, the router included (the checker).

Where a rule lives​

A business rule — may this invoice be paid, is this slot still bookable — is a function of data, not of a WidgetRef. Written as extension on WidgetRef, it reads its inputs through providers and can be tested only by booting the app around it. Put the decision in a factory on the state type, or an extension on the data object in lib/shared/, with time passed in rather than read; the provider or widget only says where the data comes from. Then the rule is a plain unit test (#226).

One-off reads: dw.client​

For a value needed once, outside any widget, the client itself is dw.client:

final result = await dw.client.fetch(const GetInvoice(invoiceId: 7)); // DwCallResult<Invoice>

A fetch is not watched: no state is kept and no update reaches the value. It validates first, retries and times out like a command. dw.files.getLink(fileId) is a fetch of this kind — see uploads on the client.