Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

Guides

Query invalidation

Mark queries stale after a write, refetch the ones on screen, and select queries by factory, group or predicate, such as tags.

Waiting for data to go stale on its own doesn't work after a write: the user just changed something and expects to see it. invalidateOne and invalidateMany mark queries stale and refetch the ones that are being read:

// One query: a factory and its input
await 
invalidateOne<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(client: ClientFor<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>, factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, input: {
    slug: string;
}): Promise<void>

Mark one query stale: a factory and its full input. If it's read, it refetches; resolves when that settles.

invalidateOne
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
,
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, { slug: stringslug });
// Every query whose key starts with the `posts` group await function invalidateMany(client: QueryClient, group: Group): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
, const posts: Group<readonly ["posts"]>posts);

When a query is invalidated:

  • It's marked stale, whatever its staleTime. The next load that starts it refetches it.
  • If something is reading it, it refetches now, in the background. Readers keep showing the current data and re-render when the new data lands.
  • If nothing is reading it, nothing happens until the next time something starts or reads it.

The verbs return a promise that resolves once every refetch they started has settled. Await it at the end of an action, and the action stays pending until the fresh data is on screen.

Try it. Each list below reads its own query. Invalidate a group, a factory, or one query, and watch which ones refetch:

Targets

Verbs are functions that take the client first. Each …One verb takes a factory and its full input; each …Many verb takes a target:

CallMatches
invalidateOne(qc, factory, input)The factory's query for that input
invalidateMany(qc, factory)Every query of the factory
invalidateMany(qc, factory, partialInput)The factory's queries whose input matches partialInput
invalidateMany(qc, group)Every key that starts with the group, or a group in it
invalidateMany(qc, { where }, param?)Every query where returns true for
invalidateMany(qc, [target, …])Every query any of the targets matches: several targets
invalidateAll(qc)Everything

A partial input matches the input each query's factory was called with, never its key, so it works the same however the key is built: [postQuery, { slug }] for a key written as posts("bySlug", input.slug). Object fields match partially: { sort: "newest" } matches any input with that field, whatever else it has.

A query that was only hydrated has no factory until something reads it. A factory target still invalidates it, from its first read. resetMany and removeMany act at once, so a factory target skips it; a group reaches it by its key.

Targets are values, never strings: a factory matches by identity, a group by its path, and a where target, such as a tag, by what it says about each query. TypeScript checks a partial input against the factory's input, and a target's parameter against the target.

Several targets

To invalidate more than one thing, pass a list. An element is a group, a factory, a target, or [factory, partialInput] / [target, param]:

// One call: the refetches run side by side, and a query that two targets
// cover refetches once
await 
invalidateMany<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}, readonly [Group<readonly ["authors"]>, (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, readonly [(input: {
    page: number;
    sort: "newest" | "oldest";
}) => QueryOptions<...>, {
    ...;
}], readonly [...]]>(client: QueryClient<...>, targets: readonly [...]): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
, [
const authors: Group<readonly ["authors"]>authors, // a group
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, // a factory: all its queries
[
function postsQuery(input: {
    page: number;
    sort: "newest" | "oldest";
}): QueryOptions<readonly ["posts", "list", {
    page: number;
    sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>
postsQuery
, { sort?: "newest" | "oldest" | undefinedsort: "newest" }], // a factory with a partial input
[const tags: Target<Tagged, string>tags, `post:${const slug: stringslug}`], // a target with its parameter ]);

The refetches run side by side, a query that several targets cover refetches once, and the promise resolves when all of them have settled. Awaiting one verb after another serializes them instead: await invalidateMany(qc, a); await invalidateMany(qc, b); starts b's refetches only once a's are done. TypeScript checks each element on its own, the way it checks a single target.

Matching with where

A target is any object with where(query, param), typed Target<TMeta, TParam>. Its where gets each query's queryKey, input, meta and data (before select), and the parameter the verb was called with:

import type { 
type QueryMeta = {
    readonly [x: string]: unknown;
}

A query's meta when its builder isn't bound to a root.

QueryMeta
, interface Target<TMeta = Readonly<Record<string, unknown>>, TParam = never>

Picks out queries by what they are: where gets each query and the parameter the verb was called with.

Target
} from "@raf-query/react";
// Every query whose key mentions a post's slug const const mentionsPost: Target<Readonly<Record<string, unknown>>, string>mentionsPost: interface Target<TMeta = Readonly<Record<string, unknown>>, TParam = never>

Picks out queries by what they are: where gets each query and the parameter the verb was called with.

Target
<
type QueryMeta = {
    readonly [x: string]: unknown;
}

A query's meta when its builder isn't bound to a root.

QueryMeta
, string> = {
Target<Readonly<Record<string, unknown>>, string>.where: (query: QueryInfo<Readonly<Record<string, unknown>>>, param: string) => booleanwhere: (query: QueryInfo<Readonly<Record<string, unknown>>>query, postSlug: stringpostSlug) => query: QueryInfo<Readonly<Record<string, unknown>>>query.QueryInfo<Readonly<Record<string, unknown>>>.queryKey: QueryKeyqueryKey.ReadonlyArray<unknown>.includes(searchElement: unknown, fromIndex?: number): boolean

Determines whether an array includes a certain element, returning true or false as appropriate.

@paramsearchElement — The element to search for.@paramfromIndex — The position in this array at which to begin searching for searchElement.
includes
(postSlug: stringpostSlug),
}; await
invalidateMany<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}, string>(client: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>, target: Target<...>, param: string): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
, const mentionsPost: Target<Readonly<Record<string, unknown>>, string>mentionsPost, const slug: stringslug);
// Every query that has no data yet await
invalidateMany<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}, never>(client: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>, target: Target<...>): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
, {
Target<NoInfer<Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<...> & Present<...>>>, never>.where: (query: QueryInfo<NoInfer<Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
    tags?: ReadonlyArray<string>;
}>>>>, param: never) => boolean
where
: (
query: QueryInfo<NoInfer<Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
    tags?: ReadonlyArray<string>;
}>>>>
query
) =>
query: QueryInfo<NoInfer<Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
    tags?: ReadonlyArray<string>;
}>>>>
query
.QueryInfo<TMeta = Readonly<Record<string, unknown>>>.data: unknown

The query's data, before select; undefined without data.

data
=== var undefinedundefined });

input is the input of the latest factory call for that entry. An entry that was only hydrated, and not yet read or started on this client, has undefined input and {} as its meta, so match on queryKey to include those. On a client of a typed root, query.meta has the root's fields: see Query meta.

Tags

A tag cuts across groups: every query that shows some content, wherever its key starts. raf-query has no tags of its own, because a tag is a where target over a meta field: a few lines in your app. A feature without a setup function declares meta.tags, and tag(name) matches the queries that list name:

import { function feature<TDecl extends Decl = Decl>(setup?: FeatureFunction): Feature<TDecl>

A feature that declares the meta fields it reads. Without setup, it only declares them: a root's own fields, such as an owner its targets match on.

feature
} from "@raf-query/react";
// Declares `meta.tags` on the clients that have it const
const tagged: Feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>
tagged
=
feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>(setup?: FeatureFunction): Feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>

A feature that declares the meta fields it reads. Without setup, it only declares them: a root's own fields, such as an owner its targets match on.

feature
<{
meta: {
    tags?: ReadonlyArray<string>;
}
meta
: { tags?: readonly string[] | undefinedtags?: interface ReadonlyArray<T>ReadonlyArray<string> } }>();
type
type Tagged = {
    readonly tags: ReadonlyArray<string> | undefined;
}
Tagged
= { readonly tags: readonly string[] | undefinedtags: interface ReadonlyArray<T>ReadonlyArray<string> | undefined };
// Every query that lists the tag const const tag: (name: string) => Target<Tagged>tag = (name: stringname: string): interface Target<TMeta = Readonly<Record<string, unknown>>, TParam = never>

Picks out queries by what they are: where gets each query and the parameter the verb was called with.

Target
<
type Tagged = {
    readonly tags: ReadonlyArray<string> | undefined;
}
Tagged
> => ({
Target<Tagged, never>.where: (query: QueryInfo<Tagged>, param: never) => booleanwhere: (query: QueryInfo<Tagged>query) => query: QueryInfo<Tagged>query.QueryInfo<Tagged>.meta: Tagged

The meta its factory gave it. An entry only hydrated has {} until it's read.

meta
.tags: readonly string[] | undefinedtags?.ReadonlyArray<string>.includes(searchElement: string, fromIndex?: number): boolean

Determines whether an array includes a certain element, returning true or false as appropriate.

@paramsearchElement — The element to search for.@paramfromIndex — The position in this array at which to begin searching for searchElement.
includes
(name: stringname) ?? false,
}); export function
function makeQueryClient(): QueryClient<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}>
makeQueryClient
() {
const {
const features: readonly [Feature<{
    readonly meta: FocusRefetchMeta;
}>, Feature<{
    readonly meta: ReconnectRefetchMeta;
}>, Feature<{
    readonly meta: PollingMeta;
}>]
features
,
const links: readonly [Link<{
    readonly meta: RetryMeta;
}>]
links
} = function recommended(): Recommended

The behaviors most apps want: retries, refetching on focus and reconnect, and polling. Spread it into the config:

recommended
();
return
createQueryClient<readonly [Link<{
    readonly meta: RetryMeta;
}>], readonly [Feature<{
    readonly meta: FocusRefetchMeta;
}>, Feature<{
    readonly meta: ReconnectRefetchMeta;
}>, Feature<{
    readonly meta: PollingMeta;
}>, Feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>]>(config?: QueryClientConfig & {
    readonly features?: readonly [Feature<{
        readonly meta: FocusRefetchMeta;
    }>, Feature<...>, Feature<...>, Feature<...>] | undefined;
    readonly links?: readonly [...] | undefined;
}): QueryClient<...>

Create a query client. Behaviors are opt-in: pass links and features, or spread recommended(). The client's meta type is what they declare.

createQueryClient
({
links?: (readonly AnyLink[] & readonly [Link<{
    readonly meta: RetryMeta;
}>]) | undefined

Wrap every request, outermost first.

links
,
features?: (readonly AnyFeature[] & readonly [Feature<{
    readonly meta: FocusRefetchMeta;
}>, Feature<{
    readonly meta: ReconnectRefetchMeta;
}>, Feature<{
    readonly meta: PollingMeta;
}>, Feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>]) | undefined

Behaviors started while a provider has the client mounted.

features
: [...
const features: readonly [Feature<{
    readonly meta: FocusRefetchMeta;
}>, Feature<{
    readonly meta: ReconnectRefetchMeta;
}>, Feature<{
    readonly meta: PollingMeta;
}>]
features
,
const tagged: Feature<{
    meta: {
        tags?: ReadonlyArray<string>;
    };
}>
tagged
] });
} export function
function postBySlugQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postBySlugQuery
(
input: {
    slug: string;
}
input
: { slug: stringslug: string }) {
return
queryOptions<readonly ["posts", "bySlug", string], Post, Post>(options: BaseOptions<readonly ["posts", "bySlug", string], DefaultTypes> & {
    readonly queryFn: (context: QueryFunctionContext<readonly ["posts", "bySlug", string]>) => Awaitable<Post>;
    readonly select?: ((data: Post) => Post) | undefined;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes> (+1 overload)

Typed options for a query factory:

queryOptions
({
BaseOptions<readonly ["posts", "bySlug", string], DefaultTypes>.queryKey: readonly ["posts", "bySlug", string]

A plain array, usually built by a group(). Handles keep its literal type.

queryKey
: posts<readonly ["bySlug", string]>(rest_0: "bySlug", rest_1: string): readonly ["posts", "bySlug", string]posts("bySlug",
input: {
    slug: string;
}
input
.slug: stringslug),
queryFn: (context: QueryFunctionContext<readonly ["posts", "bySlug", string]>) => Awaitable<Post>queryFn: () => function getPost(slug: string): Promise<Post>getPost(
input: {
    slug: string;
}
input
.slug: stringslug),
BaseOptions<readonly ["posts", "bySlug", string], DefaultTypes>.meta?: NoInfer<Partial<Readonly<Record<string, unknown>>>> | undefined

Settings for the root's links and features, and data for targets: the fields the root's parts declare, such as retry with the retry() link.

meta
: { tags: string[]tags: ["content", `post:${
input: {
    slug: string;
}
input
.slug: stringslug}`] },
}); } const
const qc: QueryClient<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}>
qc
=
function makeQueryClient(): QueryClient<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}>
makeQueryClient
();
await
invalidateMany<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}, never>(client: QueryClient<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}>, target: Target<...>): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<{
        tags?: ReadonlyArray<string>;
    }> & Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta>>;
}>
qc
, const tag: (name: string) => Target<Tagged>tag("content"));

Tagged names the field the way a client's root has it: present, and possibly undefined. Typed that way, tag() fits only clients made with tagged, and TypeScript rejects it on the others. To have one target for every tag, take the tag as the parameter:

// One target for every tag: the tag is its parameter
const const tags: Target<Tagged, string>tags: interface Target<TMeta = Readonly<Record<string, unknown>>, TParam = never>

Picks out queries by what they are: where gets each query and the parameter the verb was called with.

Target
<
type Tagged = {
    readonly tags: ReadonlyArray<string> | undefined;
}
Tagged
, string> = {
Target<Tagged, string>.where: (query: QueryInfo<Tagged>, param: string) => booleanwhere: (query: QueryInfo<Tagged>query, name: stringname) => query: QueryInfo<Tagged>query.QueryInfo<Tagged>.meta: Tagged

The meta its factory gave it. An entry only hydrated has {} until it's read.

meta
.tags: readonly string[] | undefinedtags?.ReadonlyArray<string>.includes(searchElement: string, fromIndex?: number): boolean

Determines whether an array includes a certain element, returning true or false as appropriate.

@paramsearchElement — The element to search for.@paramfromIndex — The position in this array at which to begin searching for searchElement.
includes
(name: stringname) ?? false,
}; await
invalidateMany<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}, string>(client: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>, target: Target<...>, param: string): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(
const qc: QueryClient<{
    readonly meta: Flat<Present<RetryMeta> & Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<{
        tags?: ReadonlyArray<string>;
    }>>;
}>
qc
, const tags: Target<Tagged, string>tags, `post:${const slug: stringslug}`);

To pick queries by their data instead, read query.data in where. It's undefined until a query has data.

Invalidate, reset or remove

VerbsDataRead queriesUse for
invalidate…Kept, shown meanwhileRefetch in the backgroundAfter writes: the normal case
reset…DroppedSuspend and refetchData that must not be shown again (logout)
remove…Dropped with the entryKeep what they rendered until they re-renderFreeing memory, dropping an abandoned key

Each comes as …One, …Many and …All, with the same targets; resetMany and removeMany take a list too. reset resolves when the refetches it caused settle, like invalidate. remove is synchronous, and aborts in-flight requests.

Invalidating from mutations

The common pattern is to invalidate at the end of a write, so it covers whatever the write affected. Await a verb at the end of the action:

		<React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form
			React.FormHTMLAttributes<HTMLFormElement>.action?: string | ((formData: FormData) => void | Promise<void>) | undefinedaction={async (formData: FormDataformData) => {
				await 
function addComment(input: {
    body: string;
    slug: string;
}): Promise<PostComment>
addComment
({
body: stringbody:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(formData: FormDataformData.FormData.get(name: string): FormDataEntryValue | null

The get() method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead.

get
("body")),
slug: stringslug:
props: {
    slug: string;
}
props
.slug: stringslug,
}); await function invalidateMany(client: QueryClient, group: Group): Promise<void> (+3 overloads)

Mark queries stale: a factory's (optionally only those whose input matches partial), a group's (keys that start with its path, whether the group built them or not), or those a target's where picks. Read queries refetch now; the rest when next read. Resolves when the refetches settle.

invalidateMany
(const qc: QueryClient<any>qc, const comments: Group<readonly ["posts", "comments"]>comments);
}} > {/* ... */} </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form> ); }

When a write changes more than one thing, invalidate them in one call, so their refetches run side by side. Prefer invalidating a little too much over too little. Only queries being read refetch, so a broad group costs a request per list on screen, not per list ever loaded.

When the server's response has everything the cache needs, write it with setData instead and skip the refetch. See Mutations.

Invalidation on the server

A server render's client never revalidates cached data, because it renders once: prefetch and swr use what the cache has, even after an invalidation. A revalidate refetches an invalidated entry, so it sees the write. This matters in Next.js server actions that render again after a write.