# Basic

> A list and a detail sharing one cache, a mark on every row the cache already holds, and a detail entry that is dropped once nobody reads it.

A list of posts and a detail opened from it, both reading from the same
cache. A row whose post the cache already holds is marked *cached*, reopening
that post shows it at once while a background fetch refreshes it, and a post
nobody has looked at for ten seconds is dropped again. It is the shape of a
product list with a product page, or a device list with a device screen: the
second visit to a detail should never show a spinner, and entries nobody
needs should not pile up.

Live demo: [Basic](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/basic), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic)). A list, a detail, and what the cache already knows.

## What to try

- Watch the list arrive: the `posts` debug strip reads `status=success` and
  `fetches=1`, and no row carries a *cached* mark yet.
- Open a post, then press the back arrow (*Back to list*). Its row is now
  bold and marked *cached*, and no other row is.
- Open the same post again. The title is on screen immediately, a
  *refreshing* pill shows the background fetch, and the post's strip counts
  `fetches=2` when it is done.
- Go back and wait ten seconds without opening anything. The post's entry is
  garbage-collected and its *cached* mark disappears; the list itself stays.

## The code

Two option functions: the list with the client's defaults, and one post
with a short `gcTime`, so an entry without readers is removed ten seconds
after its last one left.

[`examples/showcase/lib/features/basic/basic_screen.dart`, lines 45–49](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L45-L49):

```dart
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
    QueryObserverOptions<List<Post>>(
      queryKey: ShowcaseKeys.posts,
      queryFn: (context) => api.posts(signal: context.signal),
    );
```

[`examples/showcase/lib/features/basic/basic_screen.dart`, lines 53–58](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L53-L58):

```dart
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(id, signal: context.signal),
      gcTime: const GcTime.duration(postGcTime),
    );
```

The list reads through a `QueryBuilder`. Whether a row is cached is not part
of the list's result, so the rows ask the client with `getQueryData` and
rebuild on the cache's own events through the showcase's `CacheListener`.

[`examples/showcase/lib/features/basic/basic_screen.dart`, lines 110–152](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L110-L152):

```dart
return QueryBuilder<List<Post>>(
  options: postsQuery(api),
  builder: (context, posts) => SectionCard(
    title: 'Posts',
    trailing: posts.isFetching && posts.dataOrNull != null
        ? const Pill('refreshing')
        : null,
    child: switch (posts) {
      QueryPending() => const Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            SkeletonBox(),
            SizedBox(height: 8),
            SkeletonBox(),
            SizedBox(height: 8),
            SkeletonBox(),
          ],
        ),
      QueryError(:final error, staleData: null) =>
        Notice('$error', error: true),
      QuerySuccess(:final data) ||
      QueryError(staleData: final data!) =>
        // Whether a row's post is cached is not part of this query's
        // result: it is read straight from the cache, so the rows are
        // rebuilt on the cache's own events.
        CacheListener(
          builder: (context) => Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              for (final post in data)
                _PostRow(
                  post: post,
                  cached: client
                          .getQueryData<Post>(ShowcaseKeys.post(post.id)) !=
                      null,
                  onOpen: () => onOpen(post.id),
                ),
            ],
          ),
        ),
    },
  ),
);
```

The detail is its own widget reading `context.query`, so going back
unmounts the reader and the post's entry starts counting down its `gcTime`.

[`examples/showcase/lib/features/basic/basic_screen.dart`, lines 213–262](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L213-L262):

```dart
final post = context.query(postQuery(api, id));

return SectionCard(
  title: 'Post #$id',
  trailing: Row(
    mainAxisSize: MainAxisSize.min,
    children: <Widget>[
      // A refetch over data already on screen: upstream's "Background
      // Updating...". The first load shows the skeleton instead.
      if (post.isFetching && post.dataOrNull != null)
        const Pill('refreshing'),
      IconButton(
        tooltip: 'Back to list',
        onPressed: onBack,
        icon: const Icon(Icons.arrow_back),
      ),
    ],
  ),
  child: switch (post) {
    QueryPending() => const Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          SkeletonBox(height: 20, width: 240),
          SizedBox(height: 8),
          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>[
          if (post case QueryError(:final error)) ...<Widget>[
            Notice('Refetch failed: $error', error: true),
            const SizedBox(height: 8),
          ],
          Text(
            data.title,
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 8),
          Text(data.body),
        ],
      ),
  },
);
```

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

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

```dart
/// Upstream's `basic` example: a list of posts, a detail opened from it in
/// the same screen, and what the cache already knows shown in the list —
/// a row is marked `cached` when `getQueryData` finds its post. Reopening a
/// visited post shows it at once and refreshes it in the background; the
/// detail entry has a short `gcTime`, so a post left alone is dropped from
/// the cache ten seconds later and its mark disappears.
///
/// The list is a `QueryBuilder`, the detail reads `context.query` — two of
/// the four call styles, side by side.
///
/// Proofs (widget tests in `test/features/basic_test.dart`, end-to-end in
/// `e2e/tests/basic.spec.ts`): the list arrives after one request with no
/// row marked; opening a post fetches it once and marks its row on the way
/// back, no other row; reopening it shows the title from the cache while the
/// refetch is still in flight; the entry is collected once `gcTime` passes
/// and the mark goes with it; leaving the screen releases every observer.
library;

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

import '../../shared/api.dart';
import '../../shared/cache_listener.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 basicFeature = Feature(
  id: 'basic',
  title: 'Basic',
  summary: 'A list, a detail, and what the cache already knows.',
  upstream: 'basic',
);

/// How long a post's entry outlives its last reader. Upstream keeps posts
/// for a day; ten seconds is long enough to see the `cached` mark and short
/// enough to watch it go.
const Duration postGcTime = Duration(seconds: 10);

/// The list's query, with the client's defaults for everything else.
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
    QueryObserverOptions<List<Post>>(
      queryKey: ShowcaseKeys.posts,
      queryFn: (context) => api.posts(signal: context.signal),
    );

/// One post's query. The default `staleTime` is what makes a reopened post
/// refetch in the background; [postGcTime] is what lets it go.
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(id, signal: context.signal),
      gcTime: const GcTime.duration(postGcTime),
    );

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

  @override
  State<BasicScreen> createState() => _BasicScreenState();
}

class _BasicScreenState extends State<BasicScreen> {
  /// The open post, or null for the list — upstream's `postId` state.
  int? _selectedId;

  @override
  Widget build(BuildContext context) {
    final selectedId = _selectedId;
    return FeatureScaffold(
      feature: basicFeature,
      children: <Widget>[
        if (selectedId == null) ...<Widget>[
          // The strip goes above the list: thirty rows push anything below
          // them out of the scaffold's lazily built viewport, and a strip
          // that is not built is a strip no test can read.
          QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'),
          _PostList(onOpen: (id) => setState(() => _selectedId = id)),
        ] else ...<Widget>[
          // Its own widget, so leaving it unmounts the `context.query`
          // reader and releases the post's observer at once.
          _PostDetail(
            id: selectedId,
            onBack: () => setState(() => _selectedId = null),
          ),
          QueryDebugStrip(
            queryKey: ShowcaseKeys.post(selectedId),
            label: 'post-$selectedId',
          ),
          QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'),
        ],
      ],
    );
  }
}

class _PostList extends StatelessWidget {
  const _PostList({required this.onOpen});

  final ValueChanged<int> onOpen;

  @override
  Widget build(BuildContext context) {
    final api = ShowcaseScope.apiOf(context);
    final client = QueryClientProvider.of(context);
    return QueryBuilder<List<Post>>(
      options: postsQuery(api),
      builder: (context, posts) => SectionCard(
        title: 'Posts',
        trailing: posts.isFetching && posts.dataOrNull != null
            ? const Pill('refreshing')
            : null,
        child: switch (posts) {
          QueryPending() => const Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                SkeletonBox(),
                SizedBox(height: 8),
                SkeletonBox(),
                SizedBox(height: 8),
                SkeletonBox(),
              ],
            ),
          QueryError(:final error, staleData: null) =>
            Notice('$error', error: true),
          QuerySuccess(:final data) ||
          QueryError(staleData: final data!) =>
            // Whether a row's post is cached is not part of this query's
            // result: it is read straight from the cache, so the rows are
            // rebuilt on the cache's own events.
            CacheListener(
              builder: (context) => Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  for (final post in data)
                    _PostRow(
                      post: post,
                      cached: client
                              .getQueryData<Post>(ShowcaseKeys.post(post.id)) !=
                          null,
                      onOpen: () => onOpen(post.id),
                    ),
                ],
              ),
            ),
        },
      ),
    );
  }
}

class _PostRow extends StatelessWidget {
  const _PostRow({
    required this.post,
    required this.cached,
    required this.onOpen,
  });

  final Post post;
  final bool cached;
  final VoidCallback onOpen;

  @override
  Widget build(BuildContext context) => SemanticsGroup(
        // A named group per row, so a test can ask for row 3's mark and
        // nobody else's; the mark stays a text of its own outside the button.
        name: 'post ${post.id}',
        child: Row(
          children: <Widget>[
            Expanded(
              child: MergeSemantics(
                child: Semantics(
                  button: true,
                  child: InkWell(
                    onTap: onOpen,
                    borderRadius: BorderRadius.circular(6),
                    child: Padding(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 8, vertical: 10),
                      child: Text(
                        post.title,
                        style: cached
                            ? TextStyle(
                                fontWeight: FontWeight.bold,
                                color: Theme.of(context).colorScheme.primary,
                              )
                            : null,
                      ),
                    ),
                  ),
                ),
              ),
            ),
            if (cached) const Pill('cached'),
          ],
        ),
      );
}

class _PostDetail extends StatelessWidget {
  const _PostDetail({required this.id, required this.onBack});

  final int id;
  final VoidCallback onBack;

  @override
  Widget build(BuildContext context) {
    final api = ShowcaseScope.apiOf(context);
    final post = context.query(postQuery(api, id));

    return SectionCard(
      title: 'Post #$id',
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          // A refetch over data already on screen: upstream's "Background
          // Updating...". The first load shows the skeleton instead.
          if (post.isFetching && post.dataOrNull != null)
            const Pill('refreshing'),
          IconButton(
            tooltip: 'Back to list',
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back),
          ),
        ],
      ),
      child: switch (post) {
        QueryPending() => const Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              SkeletonBox(height: 20, width: 240),
              SizedBox(height: 8),
              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>[
              if (post case QueryError(:final error)) ...<Widget>[
                Notice('Refetch failed: $error', error: true),
                const SizedBox(height: 8),
              ],
              Text(
                data.title,
                style: Theme.of(context).textTheme.titleLarge,
              ),
              const SizedBox(height: 8),
              Text(data.body),
            ],
          ),
      },
    );
  }
}
```

</details>

## Related

- Guides: [Quick start](https://dualmeta-gmbh.github.io/query_kit/docs/quick-start.md), [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md), [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md)
- Upstream: TanStack's React [`basic`](https://github.com/TanStack/query/tree/main/examples/react/basic) example
- Tested by `test/features/basic_test.dart` (widget) and `e2e/tests/basic.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic)
