# Initial and placeholder data

> Showing something before the first fetch returns, either as initial data written to the cache or as placeholder data that is only displayed.

Four cards, each showing a post before its request has answered, in the two
ways the library offers. `initialData` is written to the cache as if it had
been fetched, and `staleTime` decides whether a fetch follows; card A seeds a
detail from the list that is already cached, and card D dates a seed with a
callback that runs only when the seed is written. `placeholderData` is only
shown, never cached, and the result says so with `isPlaceholderData`; card B
shows a fixed stand-in title, and card C keeps the previous post on screen
while the next one loads. In an app, the first shape opens a product's
detail instantly from the product list the user just scrolled; the second
fills a settings screen with a skeleton record, or keeps last month's
invoice visible while the next month loads.

Live demo: [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/initial-and-placeholder), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)). Data before the first fetch: written to the cache, or shown only.

## What to try

- In card A, within 30 seconds of the list loading, press *Open post 1*.
  The title is there at once, the detail reads `initialData source=list`,
  and the `post-1` debug strip shows `fetches=0`: the seed is dated with the
  list's own fetch time and counts as fresh for 30 seconds from it. Wait
  longer and the same seed is stale on arrival, so a fetch follows.
- Switch on *Treat initial data as old*, then open a post you have not
  opened yet. The title still shows at once, but the seed is dated a minute
  earlier, so it is stale and one fetch follows. A post opened before has an
  entry already, and no seed is consulted for it.
- Card B shows *Loading title…* with `isPlaceholderData=true` and
  `cache=empty` while its slowed request runs, then the real title with
  `isPlaceholderData=false` and `cache=post`. Press its *Refetch* button:
  the real title stays, the placeholder does not come back.
- In card C, switch from *Post 5* to *Post 6*. Post 5's title stays on
  screen with `isPlaceholderData=true` until post 6 arrives.
- In card D, pick *fresh*: `computeCalls=1`, `refetched=false`. Pick
  *backdated*: the seed shows, then the mount refetches and `refetched=true`.
  Press *Rebuild card D* or pick a mode a second time, and `computeCalls`
  stays at 1.

## The code

Card A's detail seeds itself from the cached list with
`InitialData.compute`; a callback returning `null` means no seed.
`initialDataUpdatedAt` dates the seed, and `staleTime` is measured from it.

[`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 86–98](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L86-L98):

```dart
QueryObserverOptions<Post> seededPostQuery(
  ShowcaseApi api,
  int id, {
  required Post? Function() seed,
  required DateTime? seededAt,
}) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(id, signal: context.signal),
      staleTime: const StaleTime.duration(seededPostStaleTime),
      initialData: InitialData<Post>.compute(seed),
      initialDataUpdatedAt: seededAt,
    );
```

Card B's placeholder is a fixed value. It shows while the entry has no
data and is never written to the cache.

[`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 102–113](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L102-L113):

```dart
QueryObserverOptions<Post> placeholderPostQuery(ShowcaseApi api) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(4),
      queryFn: (context) => api.post(
        4,
        signal: context.signal,
        delay: const Duration(seconds: 1),
      ),
      placeholderData: const PlaceholderData<Post>.value(
        Post(id: 4, title: 'Loading title…', body: ''),
      ),
    );
```

Card C's placeholder is computed from what the observer showed last. The
function is a top-level tear-off, so the options built on each rebuild
compare equal.

[`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 168–182](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L168-L182):

```dart
Post? keepPreviousPost(Post? previousData, Query<Post>? previousQuery) =>
    previousData;

/// Card C: post [id], with the previous post as its placeholder. Slowed a
/// little for the same reason as card B.
QueryObserverOptions<Post> previousPostQuery(ShowcaseApi api, int id) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(
        id,
        signal: context.signal,
        delay: const Duration(milliseconds: 750),
      ),
      placeholderData: const PlaceholderData<Post>.compute(keepPreviousPost),
    );
```

The read passes an `id`, so the same observer follows the key when the
post changes and has a previous post to hand to the placeholder.

[`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 285–288](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L285-L288):

```dart
final previous = watchQuery(
  previousPostQuery(api, _previousId),
  id: 'previous',
);
```

<details>
<summary>The whole screen</summary>

[`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart):

```dart
/// Data before the first fetch, the two ways: `initialData`, which is written
/// to the cache as if it had been fetched and ages by `staleTime` from
/// `initialDataUpdatedAt`; and `placeholderData`, which is only shown — never
/// cached — and flagged `isPlaceholderData` on the result. Port-specific; it
/// walks upstream's guides `initial-query-data` and `placeholder-query-data`.
///
/// Three cards, all read through `QueryMixin`'s `watchQuery`:
///
/// - **A.** A post's detail seeds itself from the cached posts list with
///   `InitialData.compute` (returning `null` when the list is not there yet,
///   which means "no seed") and dates the seed with the list's own
///   `dataUpdatedAt`. Under a 30 s `staleTime` that costs no request; a
///   switch dates the seed a minute older, and then a fetch follows.
/// - **B.** A fixed `PlaceholderData.value` shows a stand-in title while post
///   4's request is in flight, and the cache stays empty until the answer.
/// - **C.** `PlaceholderData.compute((previous, _) => previous)` — upstream's
///   `keepPreviousData` — keeps the last post on screen while the segmented
///   button switches the key to the next one. The read carries an `id`, which
///   is what makes the mixin's observer follow the key instead of starting a
///   new one (see the binding's `key_change_test.dart`).
/// - **D.** `initialDataUpdatedAtCompute`, the lazy form of
///   `initialDataUpdatedAt`: a callback consulted only when the seed is
///   actually written, so a rebuild — and a mode whose entry already holds
///   data — costs nothing. `computeCalls=` counts it. The consequence is the
///   point: `fresh` returns `null`, which the library reads as "date it
///   `clock.now()`", and the seed is inside `staleTime`, so the mount
///   fetches nothing; `backdated` returns a timestamp older than
///   `staleTime`, and the same mount refetches at once. The two modes seed
///   two different keys, so flipping back to one already seeded shows the
///   callback staying at one call. Supplying `initialDataUpdatedAt` *and*
///   `initialDataUpdatedAtCompute` is an `ArgumentError` when the options are
///   defaulted; the screen supplies only the callback.
///
/// Proofs (widget tests in `test/features/initial_and_placeholder_test.dart`,
/// end-to-end in `e2e/tests/initial_and_placeholder.spec.ts`): a detail
/// opened from a fresh list shows its title from the seed with `fetches=0`
/// and no `GET /api/posts/<id>`; the same seed dated old shows the title and
/// fetches once; the placeholder title shows with `isPlaceholderData=true`
/// while the request is held and `getQueryData` stays null, then the real
/// title with `false`; switching post 5 to 6 keeps 5's title as placeholder
/// until 6 arrives; a detail opened before the list has settled seeds nothing
/// and fetches; card D's callback shows `computeCalls=1` through any number
/// of rebuilds and through a mode selected a second time, `fresh` settles on
/// `isStale=false` with `fetches=0`, and `backdated` shows the seed with
/// `isStale=true` while its one refetch is in flight.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature initialAndPlaceholderFeature = Feature(
  id: 'initial-and-placeholder',
  title: 'Initial and placeholder data',
  summary: 'Data before the first fetch: written to the cache, or shown only.',
);

/// How long a seeded detail counts as fresh. Long enough that a seed dated
/// with the list's timestamp is fresh, short enough that one dated a minute
/// earlier is not.
const Duration seededPostStaleTime = Duration(seconds: 30);

/// The posts list card A seeds its details from.
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
    QueryObserverOptions<List<Post>>(
      queryKey: ShowcaseKeys.posts,
      queryFn: (context) => api.posts(signal: context.signal),
    );

/// Card A: post [id]'s detail, seeded by [seed] and dated [seededAt].
///
/// `InitialData.compute` is consulted when the entry is created — and again
/// on every options update until the entry has data, as upstream does — so
/// [seed] returning `null` while the list is still loading means "no seed",
/// and the detail fetches like any other query. With a seed, the entry starts
/// in `success` dated [seededAt], and [seededPostStaleTime] decides whether
/// the mount refetches.
QueryObserverOptions<Post> seededPostQuery(
  ShowcaseApi api,
  int id, {
  required Post? Function() seed,
  required DateTime? seededAt,
}) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(id, signal: context.signal),
      staleTime: const StaleTime.duration(seededPostStaleTime),
      initialData: InitialData<Post>.compute(seed),
      initialDataUpdatedAt: seededAt,
    );

/// Card B: post 4 behind a fixed placeholder. The request is slowed on
/// purpose so the placeholder is on screen long enough to see.
QueryObserverOptions<Post> placeholderPostQuery(ShowcaseApi api) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(4),
      queryFn: (context) => api.post(
        4,
        signal: context.signal,
        delay: const Duration(seconds: 1),
      ),
      placeholderData: const PlaceholderData<Post>.value(
        Post(id: 4, title: 'Loading title…', body: ''),
      ),
    );

/// Card D's mode: nothing seeded yet, or one of the two timestamps.
enum LazySeedMode {
  /// No reader, so nothing is seeded and the callback has not run.
  off,

  /// The callback returns `null`; the library dates the seed `clock.now()`.
  fresh,

  /// The callback returns a timestamp older than [seededPostStaleTime].
  backdated,
}

/// Card D's two seeds. A key each, so selecting a mode a second time meets an
/// entry that already holds data — which is where the callback is *not*
/// consulted again, and that is the guarantee the card is about.
const Post freshSeedPost =
    Post(id: 8, title: 'Seed · fresh timestamp', body: '');

/// The seed card D writes under [LazySeedMode.backdated]. Replaced on screen
/// by the real post 9 as soon as the mount's refetch lands.
const Post backdatedSeedPost =
    Post(id: 9, title: 'Seed · backdated timestamp', body: '');

/// Which post [mode] seeds, or `null` while nothing is seeded.
Post? lazySeedFor(LazySeedMode mode) => switch (mode) {
      LazySeedMode.off => null,
      LazySeedMode.fresh => freshSeedPost,
      LazySeedMode.backdated => backdatedSeedPost,
    };

/// Card D: [seed] written to the cache, dated by [seededAt] — the lazy form
/// of `initialDataUpdatedAt`.
///
/// The callback runs exactly once per entry, when the seed is written, and
/// never again: not on a rebuild, and not when the entry is met a second time
/// with data already in it. Returning `null` is "no opinion", and the library
/// falls back to `clock.now()`.
QueryObserverOptions<Post> lazySeededPostQuery(
  ShowcaseApi api, {
  required Post seed,
  required DateTime? Function() seededAt,
}) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(seed.id),
      queryFn: (context) => api.post(seed.id, signal: context.signal),
      staleTime: const StaleTime.duration(seededPostStaleTime),
      initialData: InitialData<Post>.value(seed),
      initialDataUpdatedAtCompute: seededAt,
    );

/// Upstream's `keepPreviousData`: whatever this observer showed last stands
/// in for the new key. A tear-off rather than an inline closure, so the
/// options built on every rebuild compare equal.
Post? keepPreviousPost(Post? previousData, Query<Post>? previousQuery) =>
    previousData;

/// Card C: post [id], with the previous post as its placeholder. Slowed a
/// little for the same reason as card B.
QueryObserverOptions<Post> previousPostQuery(ShowcaseApi api, int id) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(
        id,
        signal: context.signal,
        delay: const Duration(milliseconds: 750),
      ),
      placeholderData: const PlaceholderData<Post>.compute(keepPreviousPost),
    );

class InitialAndPlaceholderScreen extends StatefulWidget {
  const InitialAndPlaceholderScreen({super.key});

  @override
  State<InitialAndPlaceholderScreen> createState() =>
      _InitialAndPlaceholderScreenState();
}

class _InitialAndPlaceholderScreenState
    extends State<InitialAndPlaceholderScreen> with QueryMixin {
  /// Card A's open post, if any.
  int? _openId;
  bool _treatAsOld = false;

  /// Where the open detail's seed came from. Preset to `unused` on every
  /// open; the seed callback overwrites it when the library consults it,
  /// which it does not for an entry that already holds data.
  String _seedSource = 'unused';

  /// Card C's selected post.
  int _previousId = 5;

  /// Card D's mode, and how often its timestamp callback has run per mode.
  /// Counted rather than logged: the number is the assertion, and it must
  /// stay at one however often the card rebuilds.
  LazySeedMode _lazySeedMode = LazySeedMode.off;
  final Map<LazySeedMode, int> _lazySeedCalls = <LazySeedMode, int>{};

  void _openPost(int id) {
    setState(() {
      _openId = id;
      _seedSource = 'unused';
    });
  }

  Post? _seedFor(int id) {
    final post = queryClient
        .getQueryData<List<Post>>(ShowcaseKeys.posts)
        ?.where((post) => post.id == id)
        .firstOrNull;
    // Called inside `watchQuery`, from this build: the text below reads the
    // field after the call, so no `setState` is needed or allowed here.
    _seedSource = post == null ? 'none' : 'list';
    return post;
  }

  /// When the seed counts as fetched: the list's own timestamp, or a minute
  /// before it when the switch is on. Derived from the list rather than from
  /// a wall clock, so it is right under a test's fake clock too.
  DateTime? _seededAt() {
    final listUpdatedAt = queryClient
        .getQueryState<List<Post>>(ShowcaseKeys.posts)
        ?.dataUpdatedAt;
    if (listUpdatedAt == null) {
      return null;
    }
    return _treatAsOld
        ? listUpdatedAt.subtract(const Duration(minutes: 1))
        : listUpdatedAt;
  }

  /// Card D's `initialDataUpdatedAtCompute`. A tear-off of this method rather
  /// than a closure built in `build`, so the options a rebuild produces carry
  /// the same callback and the observer sees no change; it reads
  /// [_lazySeedMode] instead of taking it as an argument for the same reason.
  ///
  /// `null` means "no opinion": the library dates the seed `clock.now()`, so
  /// it is inside [seededPostStaleTime] and the mount fetches nothing. The
  /// backdated timestamp is derived from the posts list's own `dataUpdatedAt`
  /// rather than from a wall clock, the way card A's is — right under a
  /// test's fake clock too. With no list yet there is nothing to backdate
  /// from, and the seed is dated `clock.now()` like the fresh one.
  DateTime? _lazySeededAt() {
    final mode = _lazySeedMode;
    _lazySeedCalls.update(mode, (count) => count + 1, ifAbsent: () => 1);
    if (mode != LazySeedMode.backdated) {
      return null;
    }
    return queryClient
        .getQueryState<List<Post>>(ShowcaseKeys.posts)
        ?.dataUpdatedAt
        ?.subtract(seededPostStaleTime * 2);
  }

  @override
  Widget build(BuildContext context) {
    final api = ShowcaseScope.apiOf(context);
    final posts = watchQuery(postsQuery(api));
    final openId = _openId;
    final open = openId == null
        ? null
        : watchQuery(seededPostQuery(
            api,
            openId,
            seed: () => _seedFor(openId),
            seededAt: _seededAt(),
          ));
    final placeholder = watchQuery(placeholderPostQuery(api));
    // The `id` is what lets the observer follow the key: without it a new
    // key is a new observer, and a fresh observer has no previous data to
    // hand to `PlaceholderData.compute`.
    final previous = watchQuery(
      previousPostQuery(api, _previousId),
      id: 'previous',
    );
    final lazySeed = lazySeedFor(_lazySeedMode);
    final lazy = lazySeed == null
        ? null
        : watchQuery(lazySeededPostQuery(
            api,
            seed: lazySeed,
            seededAt: _lazySeededAt,
          ));
    // Seeding is not fetching: `dataUpdateCount` stays at zero for an entry
    // that only ever held its seed, so it says whether the mount refetched
    // without anyone reading a clock.
    final lazyState = lazySeed == null
        ? null
        : queryClient.getQueryState<Post>(ShowcaseKeys.post(lazySeed.id));
    // Read from the cache, not from the result: a placeholder is only ever
    // in the result, and this is what shows it.
    final cached = queryClient.getQueryData<Post>(ShowcaseKeys.post(4));

    return FeatureScaffold(
      feature: initialAndPlaceholderFeature,
      children: <Widget>[
        SectionCard(
          title: 'A. Initial data from another entry',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              _PostsList(posts),
              const SizedBox(height: 12),
              Wrap(
                spacing: 8,
                runSpacing: 4,
                children: <Widget>[
                  for (final id in const <int>[1, 2, 3])
                    OutlinedButton(
                      onPressed: () => _openPost(id),
                      child: Text('Open post $id'),
                    ),
                ],
              ),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Treat initial data as old'),
                subtitle: const Text(
                  'Dates the seed a minute before the list arrived — older '
                  'than staleTime, so a fetch follows.',
                ),
                value: _treatAsOld,
                onChanged: (value) => setState(() => _treatAsOld = value),
              ),
              if (open == null)
                const Text('Open a post: its detail seeds itself from the '
                    'list above.')
              else
                _PostDetail(
                  open,
                  card: 'A',
                  facts: <String>['initialData source=$_seedSource'],
                ),
            ],
          ),
        ),
        if (openId != null)
          QueryDebugStrip(
            queryKey: ShowcaseKeys.post(openId),
            label: 'post-$openId',
          ),
        SectionCard(
          title: 'B. Placeholder value',
          trailing: IconButton(
            tooltip: 'Refetch',
            onPressed: placeholder.isFetching ? null : placeholder.refetch,
            icon: const Icon(Icons.refresh),
          ),
          child: _PostDetail(
            placeholder,
            card: 'B',
            facts: <String>[
              'isPlaceholderData=${placeholder.isPlaceholderData}',
              'cache=${cached == null ? 'empty' : 'post'}',
            ],
          ),
        ),
        QueryDebugStrip(queryKey: ShowcaseKeys.post(4), label: 'post-4'),
        SectionCard(
          title: 'C. Placeholder from the previous query',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              SegmentedButton<int>(
                segments: <ButtonSegment<int>>[
                  for (final id in const <int>[5, 6, 7])
                    ButtonSegment<int>(value: id, label: Text('Post $id')),
                ],
                selected: <int>{_previousId},
                onSelectionChanged: (selection) =>
                    setState(() => _previousId = selection.first),
              ),
              const SizedBox(height: 12),
              _PostDetail(
                previous,
                card: 'C',
                facts: <String>[
                  'isPlaceholderData=${previous.isPlaceholderData}',
                ],
              ),
            ],
          ),
        ),
        QueryDebugStrip(
          queryKey: ShowcaseKeys.post(_previousId),
          label: 'post-$_previousId',
        ),
        SectionCard(
          title: 'D. A seed timestamp computed lazily',
          trailing: IconButton(
            tooltip: 'Rebuild card D',
            onPressed: () => setState(() {}),
            icon: const Icon(Icons.refresh),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'initialDataUpdatedAtCompute is the lazy form of '
                'initialDataUpdatedAt: it runs only when the seed is actually '
                'written. fresh returns null, which the library reads as '
                'clock.now(), so the seed is inside the 30 s staleTime and '
                'the mount fetches nothing. backdated returns a minute '
                'earlier, so the same mount refetches at once. Picking a mode '
                'again meets an entry that already holds data, and the '
                'callback is not consulted a second time.',
                style: Theme.of(context).textTheme.bodySmall,
              ),
              const SizedBox(height: 12),
              // Explicit child nodes: without them the three segments fold
              // into one label and `fresh` is not a text a test can read.
              SemanticsGroup(
                name: 'lazy-seed mode',
                child: SegmentedButton<LazySeedMode>(
                  showSelectedIcon: false,
                  segments: const <ButtonSegment<LazySeedMode>>[
                    ButtonSegment<LazySeedMode>(
                      value: LazySeedMode.off,
                      label: Text('off'),
                    ),
                    ButtonSegment<LazySeedMode>(
                      value: LazySeedMode.fresh,
                      label: Text('fresh'),
                    ),
                    ButtonSegment<LazySeedMode>(
                      value: LazySeedMode.backdated,
                      label: Text('backdated'),
                    ),
                  ],
                  selected: <LazySeedMode>{_lazySeedMode},
                  onSelectionChanged: (selection) =>
                      setState(() => _lazySeedMode = selection.first),
                ),
              ),
              const SizedBox(height: 12),
              if (lazy == null)
                const Text('Nothing seeded: pick a timestamp above.')
              else
                _PostDetail(
                  lazy,
                  card: 'D',
                  facts: <String>[
                    'mode=${_lazySeedMode.name}',
                    'computeCalls=${_lazySeedCalls[_lazySeedMode] ?? 0}',
                    'refetched=${(lazyState?.dataUpdateCount ?? 0) > 0}',
                  ],
                ),
            ],
          ),
        ),
        if (lazySeed != null)
          QueryDebugStrip(
            queryKey: ShowcaseKeys.post(lazySeed.id),
            label: 'lazy-seed',
          ),
      ],
    );
  }
}

/// The first few posts of the list — the ones the cards below use — each as
/// `#id · title`, so a title on its own is always a detail's.
class _PostsList extends StatelessWidget {
  const _PostsList(this.posts);

  final QueryResult<List<Post>> posts;

  static const int _shown = 7;

  @override
  Widget build(BuildContext context) => switch (posts) {
        QueryPending() => const Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              SkeletonBox(),
              SizedBox(height: 4),
              SkeletonBox(),
              SizedBox(height: 4),
              SkeletonBox(),
            ],
          ),
        QueryError(:final error, staleData: null) =>
          Notice('$error', error: true),
        QuerySuccess(:final data) ||
        QueryError(staleData: final data!) =>
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('posts=${data.length}',
                  style: Theme.of(context).textTheme.labelLarge),
              for (final post in data.take(_shown))
                Text('#${post.id} · ${post.title}'),
              if (data.length > _shown)
                Text('… and ${data.length - _shown} more',
                    style: Theme.of(context).textTheme.bodySmall),
            ],
          ),
      };
}

/// One post's title and the facts a test reads, or a skeleton while it has
/// nothing to show.
///
/// A group named `detail <card>`, the way the debug strip is one: two cards
/// show `isPlaceholderData=false` at once, and a test has to say which one it
/// means.
class _PostDetail extends StatelessWidget {
  const _PostDetail(this.post, {required this.card, required this.facts});

  final QueryResult<Post> post;
  final String card;
  final List<String> facts;

  @override
  Widget build(BuildContext context) => SemanticsGroup(
        name: 'detail $card',
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            switch (post) {
              QueryPending() => const SkeletonBox(height: 20, width: 240),
              QueryError(:final error, staleData: null) =>
                Notice('$error', error: true),
              QuerySuccess(:final data) ||
              QueryError(staleData: final data!) =>
                Text(
                  data.title,
                  style: Theme.of(context).textTheme.titleLarge,
                ),
            },
            const SizedBox(height: 4),
            Wrap(
              spacing: 12,
              runSpacing: 2,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: <Widget>[
                FactList(facts, dense: true),
                if (post.isFetching) const Pill('fetching'),
              ],
            ),
          ],
        ),
      );
}
```

</details>

## Related

- Guides: [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md), [Placeholder query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md)
- Tested by `test/features/initial_and_placeholder_test.dart` (widget) and `e2e/tests/initial_and_placeholder.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)
