Comparing with TanStack Query
This page is for people who know TanStack Query (also called React Query). It shows how to get the same results with Kiss, which does things in a different way. Each section shows the TanStack Query code first, and then the Kiss code that does the same.
TanStack Query is an asynchronous state management package based on queries and a cache. A query fetches data from a server (with fetch, axios, ky, etc.), and the result goes into a cache that the library owns. The library then decides when the data is stale, and fetches it again by itself: when a component mounts, when the user comes back to the tab, when the internet comes back, at intervals, or when you "invalidate" the query.
Kiss is based on actions and state. An action may call the server, and its reducer may change the state. The state belongs to your app, and you shape it as you like. There is no cache apart from the state. Components dispatch actions and read the state.
So, every time TanStack Query "fetches a query again", Kiss "dispatches an action again". And every time TanStack Query asks "is this query loading, stale, or failed?", Kiss asks "is this action running, fresh, or failed?".
Not only queries
While Redux ignores async processes, leaving them to middleware (like thunks), TanStack Query takes the opposite approach, treating all state management as querying, fetching, and caching.
In my opinion, viewing state management in terms of queries is the wrong abstraction. See this article in Medium: What I learned from React-Query, and why I will not use it in my next project
Kiss manages state through a synchronous global state, and actions that can be sync or async. Actions can fetch data, but they can also change the state in any other way. Actions can fail or succeed. You can wait for them to finish, retry them, debounce them, throttle them, and do optimistic updates.
| TanStack Query | Redux Toolkit | Kiss | |
|---|---|---|---|
| Sync processes | No | Actions/Reducers | Actions/Reducers |
| Async processes | Queries | Thunks | Actions/Reducers |
| Global local state | No | Yes | Yes |
| Local persistence | No | Objects | Objects and classes |
| Loading and failed states | Yes | No | Yes |
| Deduplication | Yes | No | Yes |
| Smart refetches | Yes | No | Yes |
| Retry | Yes | No | Yes |
| Optimistic updates | Manual | No | Yes |
| Server-side rendering | Yes | Yes | Yes |
Quick translation
If you know TanStack Query, this table tells you where to look in Kiss. The sections below explain each line, with code.
| TanStack Query | Kiss |
|---|---|
useQuery with a queryFn | An async action, whose reduce() fetches the data and puts it in the state |
| The cache | The state |
queryKey | The action class, and its constructor parameters |
data | useSelect((state) => state.something) |
select, structuralSharing | useSelect, useObject |
initialData, placeholderData | The initialState of the store |
isPending, isError, error | useIsWaiting(Action), useIsFailed(Action), useExceptionFor(Action) |
isFetching while there is data (a refresh) | useIsWaiting(Action), while the state already has the data |
Status per queryKey | statusKey in the action, and { statusKey } in the hooks |
useSuspenseQuery, throwOnError | useSuspense(Action) |
staleTime | fresh |
refetchOnMount | useStoreEffect({ onMount }) |
refetchOnWindowFocus | useStoreEffect({ onFocus }) |
refetchOnReconnect | useStoreEffect({ onOnline }) |
refetchInterval | Polling: pollInterval, Poll.start, Poll.stop |
refetchIntervalInBackground: false | pollOnlyWhenFocused and pollOnlyWhenOnline (the defaults) |
focusManager, onlineManager | store.isFocused, store.isOnline, useIsFocused(), useIsOnline() |
focusManager.setEventListener, onlineManager.setEventListener | focusSource, onlineSource in createStore |
enabled: false, dependent queries, skipToken | Don't dispatch; or dispatchAndWait, dispatchWhen, waitCondition |
useQueries | dispatchAll, dispatchAndWaitAll |
placeholderData: keepPreviousData | ignoreOld (the state keeps the old data until the new one arrives) |
prefetchQuery, ensureQueryData | Dispatch the action early (for example, on mouse hover), with fresh |
signal in the queryFn, cancelQueries | this.abortSignal, aborted by ignoreOld, store.abort or action.abort() |
retry, retryDelay | retry |
retry: (failureCount, error) => boolean | shouldRetry(error, attempt) |
networkMode: 'online' (paused while offline) | checkInternet, unlimitedRetryCheckInternet |
useMutation | An action (sync or async) |
onSuccess, onError, onSettled | The reducer, wrapError(), after() |
onMutate and rollback (optimistic update) | OptimisticCommand, OptimisticSync, OptimisticSyncWithPush |
Mutation scope (one mutation at a time) | sequential |
useInfiniteQuery, fetchNextPage, hasNextPage | LoadPages, dispatched with 'next', and the Pages value in the state |
invalidateQueries | Not needed: the action that saves reloads what it needs |
setQueryData | A sync action |
useIsFetching, useIsMutating | useIsWaiting(A) || useIsWaiting(B); or store.actionsInProgress() |
dehydrate, hydrate, HydrationBoundary | serverState, StoreSlice |
persistQueryClient | The persistor |
QueryClient.clear(), removeQueries | store.logOut(), store.clearInternalActionProps(), a reset action |
gcTime (forget unused data) | Not needed: dispatch a clean-up action in onUnmount |
| Devtools | actionObserver, stateObserver (no UI) |
onlineManager.setOnline(false) in tests | store.setOnline(false), store.forceInternetOnOffSimulation |
| Debounce and throttle (not in TanStack Query) | debounce, throttle |
Queries and the state
In TanStack Query, a component declares a query with a key and a function that fetches. The
result is kept in the cache, and comes back as data:
function TextScreen() {
const { data } = useQuery({ queryKey: ['text'], queryFn: () => api.loadText() });
return <pre>{data}</pre>;
}
In Kiss, an action fetches the data in its reduce() method, and puts it in the state. The
component dispatches the action when it mounts, and reads the state with useSelect:
class LoadText extends Action {
async reduce() {
const text = await api.loadText();
return (state: State) => state.copy({ text });
}
}
function TextScreen() {
useStoreEffect({ onMount: (store) => store.dispatch(new LoadText()) });
const text = useSelect((state: State) => state.text);
return <pre>{text}</pre>;
}
This is how each part translates:
- The
queryFnis thereduce()method of the action. - The cache is the state. Since the state belongs to your app, you decide its shape.
- The
queryKeyis the action class, plus its constructor parameters (new LoadUser(userId)). datais whateveruseSelectreads from the state. Theselectoption isuseSelecttoo, anduseObjectdoes whatstructuralSharingdoes: the component only re-renders when the selected value changes.initialDataandplaceholderDataare theinitialStateyou give tocreateStore.
Global local state
TanStack Query has a shared cache for all queries. The cache is global sync state, but it's an implementation detail for handling fresh and stale data. It's not meant for direct access. That's why the table above says TanStack Query has no "global local state".
If we want to abuse the cache as local state, we can do something like this:
function useGlobalLocalState(key, initialValue) {
const { data: state } = useQuery({
queryKey: [key],
queryFn: () => queryClient.getQueryData([key]),
initialData: initialValue,
});
const setState = (value) => queryClient.setQueryData([key], value);
return [state, setState];
}
function Counter() {
const [count, setCount] = useGlobalLocalState('count', 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>add</button>
</div>
);
}
You have to manage globally unique keys by hand (like 'count' above), so that they don't
repeat, and use initialData. And there is no type safety when calling setQueryData.
Kiss has a global local state that is meant for direct access:
function Counter() {
const count = useSelect((state: State) => state.count);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(new IncrementCount())}>add</button>
</div>
);
}
Loading and failed states
This is how TanStack Query lets a component know if a query is loading or has failed:
function TextScreen() {
const { data, error, isPending, isError } = useQuery({
queryKey: ['text'],
queryFn: fetchText,
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return <pre>{data}</pre>;
}
This is how Kiss does it (see Wait, fail, succeed):
function TextScreen() {
const text = useSelect((state: State) => state.text);
const isWaiting = useIsWaiting(LoadText);
const isFailed = useIsFailed(LoadText);
const error = useExceptionFor(LoadText);
if (isWaiting) return <div>Loading...</div>;
if (isFailed) return <div>Error: {error?.message}</div>;
return <pre>{text}</pre>;
}
Note useIsFailed is true for any error, but useExceptionFor only returns UserExceptions.
To have a message for other errors too, like a failed fetch, the action turns them into
UserExceptions in wrapError():
class LoadText extends Action {
async reduce() { ... }
wrapError(error: any) {
return (error instanceof UserException)
? error
: new UserException('Failed to load the text.', { hardCause: error });
}
}
One difference: TanStack Query ties the component to the query. The component that calls
useQuery is the one that fetches, so one component can't easily know if a query started by
another component is loading. In Kiss, useIsWaiting(LoadText) and useIsFailed(LoadText)
work in any component, no matter which one dispatched the action.
First load and refresh
TanStack Query separates isPending (there is no data yet) from isFetching (a request is
running, maybe while the old data is shown). This lets you show a big spinner on the first
load, and a small one on a refresh:
const { data, isPending, isFetching } = useQuery({ queryKey: ['text'], queryFn: fetchText });
if (isPending) return <Spinner />;
return <>{isFetching && <SmallSpinner />}<pre>{data}</pre></>;
In Kiss the data is in the state, so the component just looks at it. On the first load the state is still empty. On a refresh, it already has data:
const text = useSelect((state: State) => state.text);
const isWaiting = useIsWaiting(LoadText);
if (text === null) return <Spinner />;
return <>{isWaiting && <SmallSpinner />}<pre>{text}</pre></>;
Status per key
TanStack Query's status is per query key: isPending for user 1 is separate from user 2:
const { isPending } = useQuery({ queryKey: ['user', userId], queryFn: () => api.loadUser(userId) });
Kiss's useIsWaiting(LoadUser) is true while any LoadUser runs. To get the status of
one user, give the action a statusKey, and pass the same value to the hooks:
class LoadUser extends Action {
constructor(readonly userId: string) { super(); }
get statusKey() { return this.userId; }
async reduce() { ... }
}
const isWaiting = useIsWaiting(LoadUser, { statusKey: userId });
const isFailed = useIsFailed(LoadUser, { statusKey: userId });
const error = useExceptionFor(LoadUser, { statusKey: userId });
See Status per key.
Suspense and error boundaries
If you use useSuspenseQuery and throwOnError, with <Suspense> and error boundaries:
function UserName({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => api.loadUser(userId) });
return <p>{user.name}</p>;
}
Kiss has useSuspense(LoadUser). It suspends the component while LoadUser runs, and throws
the error to the nearest error boundary if it failed. The data is read from the state, as
always:
function UserName({ userId }: { userId: string }) {
useSuspense(LoadUser, { statusKey: userId });
const user = useSelect((state: State) => state.users[userId]);
return <p>{user.name}</p>;
}
In both cases, the component goes inside a <Suspense> and an error boundary:
<ErrorBoundary fallback={<p>Loading failed.</p>}>
<Suspense fallback={<Spinner />}>
<UserName userId={userId} />
</Suspense>
</ErrorBoundary>
Pass { throwErrors: false } to only suspend, and handle the failure with useIsFailed. See
Suspense and error boundaries.
Caching and stale data
TanStack Query assumes the server, not the frontend, owns the data. When your component needs
data, the library gives it from the cache if it's fresh, and fetches it from the server if it's
stale. The staleTime option says for how long the data is fresh. The default is 0 seconds, so
by default a query fetches again every time a component uses it:
useQuery({ queryKey: ['text'], queryFn: fetchText, staleTime: 5000 });
Kiss needs no cache, because the state has all the information. It assumes both the frontend and the server own some of the data, and helps you keep them in sync. When your component needs data, it reads the state, synchronously. When the data is stale, you dispatch an action that fetches it again and puts it in the state.
Kiss has the same default as TanStack Query (an action runs every time it's dispatched), but
uses the fresh feature. Each action decides for
how long its data is fresh. With fresh = 5000, its data is fresh for 5 seconds. Dispatching
it again in that time does nothing:
class LoadText extends Action {
fresh = 5000;
async reduce() { ... }
}
If the action fails, its data is not fresh, so the next dispatch runs right away.
In TanStack Query, the queryKey separates the data of different things (user 1, user 2):
useQuery({ queryKey: ['user', userId], queryFn: () => api.loadUser(userId), staleTime: 5000 });
In Kiss, the action's parameters do that. Override freshKeyParams() to give each user their
own fresh period:
class LoadUser extends Action {
constructor(readonly userId: string) { super(); }
fresh = 5000;
freshKeyParams() { return this.userId; }
async reduce() { ... }
}
Deduplication
TanStack Query deduplicates queries by their query key. If a page has 3 components that use the same query key, only one request is made:
function Header() { const { data } = useQuery({ queryKey: ['todos'], queryFn: api.loadTodos }); ... }
function List() { const { data } = useQuery({ queryKey: ['todos'], queryFn: api.loadTodos }); ... }
In Kiss this is not necessary, because the components read the state, not the server. When you enter the page, one component dispatches the action that fetches the list and puts it in the state. The other components read the list from the state:
function TodosPage() {
useStoreEffect({ onMount: (store) => store.dispatch(new LoadTodos()) });
return <><Header /><List /></>;
}
function Header() { const todos = useSelect((state: State) => state.todos); ... }
function List() { const todos = useSelect((state: State) => state.todos); ... }
If you still need to prevent an action from running twice at the same time, add nonReentrant to it:
class LoadTodos extends Action {
nonReentrant = true;
async reduce() { ... }
}
Debouncing and throttling
TanStack Query has no debouncing or throttling. In Kiss, use the debounce feature to limit how often an action runs when it's dispatched many times in a row, like when the user types in a search box:
class SearchText extends Action {
constructor(readonly text: string) { super(); }
debounce = 300;
async reduce() { ... }
}
And the throttle feature to run an action at most once in a period, no matter how many times it's dispatched:
class LoadPrices extends Action {
throttle = 1000;
async reduce() { ... }
}
Keeping the old data while the new one loads
In TanStack Query, when the query key changes (the user picks another page or filter), the
data becomes undefined until the new data arrives, unless you use
placeholderData: keepPreviousData:
useQuery({
queryKey: ['items', page],
queryFn: () => api.items(page),
placeholderData: keepPreviousData,
});
In Kiss, the state keeps the old data until the action puts the new one there. Nothing becomes
undefined. The only risk is a slow old request finishing after a newer one, and
overwriting the newer data. The ignoreOld
feature prevents that: when the action is dispatched again, the older ones still running are
ignored, and their requests are cancelled:
class LoadPage extends Action {
constructor(readonly page: number) { super(); }
ignoreOld = true;
async reduce() {
const response = await fetch(`/api/items?page=${this.page}`, { signal: this.abortSignal });
const items = await response.json();
return (state: State) => state.copy({ items });
}
}
Refetching
TanStack Query fetches a query again by itself, in four situations: when a component mounts
(refetchOnMount), when the user comes back to the browser tab (refetchOnWindowFocus), when
the internet comes back (refetchOnReconnect), and at intervals (refetchInterval). The
first three are on by default, and only fetch if the data is stale:
useQuery({
queryKey: ['text'],
queryFn: fetchText,
staleTime: 10000,
refetchOnMount: true, // The default.
refetchOnWindowFocus: true, // The default.
refetchOnReconnect: true, // The default.
});
In Kiss, the component says when to dispatch the action, with
useStoreEffect.
And the action says if the data is still fresh, with fresh:
function TextScreen() {
useStoreEffect({
onMount: (store) => store.dispatch(new LoadText()),
onFocus: (store) => store.dispatch(new LoadText()),
onOnline: (store) => store.dispatch(new LoadText()),
});
...
}
class LoadText extends Action {
fresh = 10000;
async reduce() { ... }
}
This dispatches LoadText when the component mounts, when the user comes back to the tab, and
when the internet comes back. But it only fetches if the last fetch was more than 10 seconds
ago. Without fresh, it fetches every time, like refetchOnWindowFocus: 'always'.
onFocus and onOnline run only on the transitions (the tab becomes visible, the internet
comes back), never on mount. onBlur and onOffline run on the opposite transitions.
Polling
To fetch at intervals, TanStack Query has refetchInterval. By default, the interval pauses
while the tab is hidden (refetchIntervalInBackground: false):
useQuery({ queryKey: ['text'], queryFn: fetchText, refetchInterval: 5000 });
In Kiss, use polling. For example, to fetch the text every 5 seconds while the component is on screen:
function TextScreen() {
useStoreEffect({
onMount: (store) => store.dispatch(new LoadText(Poll.start)),
onUnmount: (store) => store.dispatch(new LoadText(Poll.stop)),
});
...
}
class LoadText extends Action {
constructor(readonly poll = Poll.once) { super(); }
pollInterval = 5000;
createPollingAction() { return new LoadText(); }
async reduce() { ... }
}
Like TanStack Query, the polling pauses while the user is on another tab, and also while the
app is offline. A tick that comes due while the user is away runs as soon as the user comes
back. To poll in the background anyway, set pollOnlyWhenFocused = false or
pollOnlyWhenOnline = false in the action. See
Polling only while the app is focused and online.
One difference: in TanStack Query, the interval belongs to the query, and runs while any component uses the query. In Kiss, the polling is started and stopped by the actions you dispatch. If two components need the same polling, start it in a parent component, or in an action that runs when the user enters that part of the app.
Knowing if the app is focused and online
TanStack Query has a focusManager and an onlineManager. They tell if the tab is visible and
if the app is online, and you can replace where they get it from (for example, in React
Native):
import { focusManager, onlineManager } from '@tanstack/react-query';
onlineManager.isOnline();
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((s) => setOnline(s.isConnected === true)));
Kiss has store.isFocused and store.isOnline. In the browser, they follow
document.visibilityState and navigator.onLine. To show them in a component, use
useIsFocused() and useIsOnline():
function OfflineBanner() {
const isOnline = useIsOnline();
return isOnline ? null : <Banner>You are offline.</Banner>;
}
To get them from somewhere else, give createStore a focusSource or an onlineSource. All
the features that depend on them (onFocus, onOnline, polling, checkInternet) follow it:
const store = createStore<State>({
initialState: State.initialState,
onlineSource: (setOnline) => NetInfo.addEventListener((s) => setOnline(s.isConnected === true)),
});
See Where "focused" and "online" come from.
Dependent and parallel queries
In TanStack Query, a query that depends on another uses enabled: false until the first one
has data:
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => api.loadUser(userId) });
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => api.loadPosts(user!.id),
enabled: !!user,
});
In Kiss, an action can simply dispatch the next one, or wait for it:
class LoadUserAndPosts extends Action {
constructor(readonly userId: string) { super(); }
async reduce() {
await this.dispatchAndWait(new LoadUser(this.userId));
this.dispatch(new LoadPosts(this.state.user.id));
return null;
}
}
Or, from a component, with dispatchWhen, which dispatches an action when a state condition becomes true:
store.dispatchWhen(new LoadPosts(userId), (state) => state.user !== null);
See also waitCondition.
For parallel queries, TanStack Query has useQueries:
useQueries({
queries: [
{ queryKey: ['user', id], queryFn: () => api.loadUser(id) },
{ queryKey: ['posts', id], queryFn: () => api.loadPosts(id) },
],
});
In Kiss, use dispatchAll, or dispatchAndWaitAll to wait for all of them:
await store.dispatchAndWaitAll([new LoadUser(id), new LoadPosts(id)]);
Infinite queries
TanStack Query loads a list page by page with useInfiniteQuery. You give it the first page
param, a queryFn that loads one page, and getNextPageParam to get the param of the next
page from the last page loaded. It keeps the pages in data.pages, and gives you
fetchNextPage, hasNextPage and isFetchingNextPage:
function TodoList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['todos'],
queryFn: ({ pageParam, signal }) => api.todosAfter(pageParam, 20, signal),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.length < 20 ? null : lastPage.at(-1).id,
});
const items = data?.pages.flat() ?? [];
...
<button disabled={!hasNextPage || isFetchingNextPage} onClick={() => fetchNextPage()}>Load more</button>
}
In Kiss, extend LoadPages. The same three things go in
the action, with the same names, and the pages go in the state, in a Pages value:
class LoadTodos extends LoadPages<State, Todo[], string | null> {
getPagesFromState(state: State) { return state.todos; }
applyPagesToState(state: State, todos: Pages<Todo[], string | null>) { return state.copy({ todos }); }
initialPageParam() { return null; }
loadPage(cursor: string | null) { return api.todosAfter(cursor, 20, this.abortSignal); }
nextPageParam(lastPage: Todo[]) { return lastPage.length < 20 ? null : lastPage.at(-1)!.id; }
}
function TodoList() {
useStoreEffect({ onMount: (store) => store.dispatch(new LoadTodos()) });
const todos = useSelect((state: State) => state.todos);
const isLoading = useIsWaiting(LoadTodos);
const dispatch = useDispatch();
const items = todos.pages.flat();
...
<button disabled={!todos.hasNextPage || isLoading} onClick={() => dispatch(new LoadTodos('next'))}>Load more</button>
}
This is how each part translates:
| TanStack Query | Kiss |
|---|---|
initialPageParam | initialPageParam() |
queryFn: ({ pageParam, signal }) | loadPage(pageParam), with this.abortSignal |
getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) | nextPageParam(lastPage, allPages, lastPageParam, allPageParams) |
getPreviousPageParam | previousPageParam |
maxPages | maxPages |
data.pages, data.pageParams | pages.pages, pages.pageParams (the Pages value, read from the state) |
hasNextPage, hasPreviousPage | pages.hasNextPage, pages.hasPreviousPage |
fetchNextPage(), fetchPreviousPage() | dispatch(new LoadTodos('next')), dispatch(new LoadTodos('previous')) |
refetch() (all the pages again, from the first) | dispatch(new LoadTodos()), or dispatch(new LoadTodos('refresh')) |
resetQueries (back to the first page) | dispatch(new LoadTodos('first')) |
isFetching, isFetchingNextPage | useIsWaiting(LoadTodos) |
isError, isFetchNextPageError | useIsFailed(LoadTodos), useExceptionFor(LoadTodos) |
setQueryData (to remove an item, etc.) | A sync action that puts pages.copy({ pages: ... }) in the state |
The rules are the same. A 'next' adds the page to the end, and maxPages drops the
first page when the limit is passed. A 'refresh' loads the pages again one after the
other, from the first page param, getting each new param from nextPageParam, so the pages
stay consistent, and changes the state once, at the end. hasNextPage is false when
nextPageParam returns null or undefined. The page param can be a cursor from the
server, the id of the last item, or a page number.
Two differences:
-
TanStack Query warns that calling
fetchNextPagewhile a fetch is running can overwrite the data, and tells you to check!isFetchingfirst. Kiss does that for you: a'next'dispatched while the list is loading is aborted, so two quick taps load one page, and a'first'or'refresh'aborts the loads that are running (likecancelRefetch: true). -
The query key separates the lists (the comments of post 1, of post 2). In Kiss, override
pagesKeyParams()to return the post id. It's also thestatusKey, souseIsWaiting(LoadComments, { statusKey: postId })tells if that list is loading.
Prefetching
TanStack Query has prefetchQuery, to fetch data before a component needs it (for example,
when the mouse hovers a link). The component that mounts later finds the data in the cache,
still fresh:
const queryClient = useQueryClient();
<Link
onMouseEnter={() => queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: () => api.loadUser(id), staleTime: 10000 })}
to={`/users/${id}`}
/>
In Kiss, just dispatch the action early. If the action has fresh, the component that mounts
later dispatches it again, and nothing happens, because the data is still fresh:
<Link onMouseEnter={() => dispatch(new LoadUser(id))} to={`/users/${id}`} />
class LoadUser extends Action {
constructor(readonly userId: string) { super(); }
fresh = 10000;
freshKeyParams() { return this.userId; }
async reduce() { ... }
}
Cancelling
TanStack Query gives the queryFn a signal, and cancels the fetch when the query is
cancelled with cancelQueries:
useQuery({
queryKey: ['items', page],
queryFn: ({ signal }) => fetch(`/api/items?page=${page}`, { signal }).then((r) => r.json()),
});
queryClient.cancelQueries({ queryKey: ['items'] });
In Kiss, every action has this.abortSignal. Pass it to fetch (see the LoadPage example
above). Then, call store.abort with the action class, for example from a "Cancel" button, or
when the component unmounts:
store.abort(LoadPage); // All the LoadPage actions in progress.
store.abort(LoadPage, { statusKey: page }); // Only the ones with this statusKey.
useStoreEffect({ onUnmount: (store) => store.abort(LoadPage) });
The aborted action finishes silently: its result and errors are ignored, and the state is
unchanged. The signal is also aborted when a newer action of the same class is dispatched
(with ignoreOld), and by action.abort() on one action. See
aborting actions.
Retry
TanStack Query retries a failed query 3 times, silently, with exponential backoff, before it shows the error:
useQuery({
queryKey: ['text'],
queryFn: fetchText,
retry: 3, // The default.
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // The default.
});
Kiss doesn't retry by default, but you can turn it on with retry = { on: true }. The delays
are set in the same object:
class LoadText extends Action {
retry = { on: true, maxRetries: 3, initialDelay: 1000, multiplier: 2, maxDelay: 30000 };
async reduce() { ... }
}
The defaults are here.
Giving up on some errors
Some errors are permanent: a 404, a 401, a validation error from the server. Retrying them
wastes time. In TanStack Query, retry can be a function that decides:
useQuery({
queryKey: ['user', userId],
queryFn: () => api.loadUser(userId),
retry: (failureCount, error) => !(error instanceof NotFoundError) && failureCount < 3,
});
In Kiss, override shouldRetry(error, attempt). It returns true by default, for every error:
class LoadUser extends Action {
retry = { on: true, maxRetries: 3 };
shouldRetry(error: unknown, attempt: number) {
return !(error instanceof NotFoundError);
}
async reduce() { ... }
}
When it returns false, the action fails right away with that error. It also works with
unlimitedRetryCheckInternet (see below), where it's the only way to stop an action that would
otherwise retry forever. See Giving up on some errors.
Offline
TanStack Query has a networkMode option. With the default, 'online', a query doesn't run
while the app is offline. It's paused, and runs when the internet comes back:
useQuery({ queryKey: ['text'], queryFn: fetchText, networkMode: 'online' }); // The default.
Kiss has two internet features for this. With
checkInternet, the action fails (or is aborted) if there is no internet. With
unlimitedRetryCheckInternet, the action waits for the internet, and runs when it's back. It
also retries if the action fails:
class LoadText extends Action {
checkInternet = true;
async reduce() { ... }
}
class SaveNote extends Action {
unlimitedRetryCheckInternet = true;
async reduce() { ... }
}
Also, onOnline in useStoreEffect dispatches an action when the internet comes back, and
polling pauses while offline, by default (see Refetching). To show an "offline"
banner, use useIsOnline(). In React Native, give createStore an onlineSource, and all of
the above follow it (see Knowing if the app is focused and online).
Mutations
In TanStack Query, changing data on the server is a mutation, with useMutation. It gives
you mutate, isPending, isError, and callbacks like onSuccess, onError and
onSettled:
function SaveButton({ text }: { text: string }) {
const { mutate, isPending, isError } = useMutation({
mutationFn: (text: string) => api.saveText(text),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['text'] }),
onError: (error) => showError(error),
onSettled: () => log('done'),
});
return <button onClick={() => mutate(text)} disabled={isPending}>Save</button>;
}
In Kiss, a mutation is just an action. It sends the change to the server in reduce(), and
changes the state when the server answers. That's the onSuccess. The wrapError() method
is the onError, and after() is the onSettled, since it always runs. The component shows
the progress with useIsWaiting(SaveText) and useIsFailed(SaveText), like for any action:
class SaveText extends Action {
constructor(readonly text: string) { super(); }
async reduce() {
await api.saveText(this.text);
return (state: State) => state.copy({ text: this.text }); // onSuccess.
}
wrapError(error: any) { return new UserException('Could not save.', { hardCause: error }); } // onError.
after() { log('done'); } // onSettled.
}
function SaveButton({ text }: { text: string }) {
const dispatch = useDispatch();
const isSaving = useIsWaiting(SaveText);
return <button onClick={() => dispatch(new SaveText(text))} disabled={isSaving}>Save</button>;
}
A UserException returned by wrapError() is shown to the user in a dialog, by default, so
you don't need the showError call.
Optimistic updates
In TanStack Query, an optimistic update is done by hand, in onMutate: you cancel the queries
in flight, save the old data, change the cache, and put the old data back in onError:
useMutation({
mutationFn: (todo: Todo) => api.addTodo(todo),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) => [...(old ?? []), todo]);
return { previous };
},
onError: (_error, _todo, context) => queryClient.setQueryData(['todos'], context?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
In Kiss, there are ready-made action classes, so you don't write the rollback by hand. See Optimistic features:
class AddTodo extends OptimisticCommand<State, Todo[]> {
constructor(readonly todo: Todo) { super(); }
optimisticValue() { return [...this.state.todos, this.todo]; }
getValueFromState(state: State) { return state.todos; }
applyValueToState(state: State, todos: Todo[]) { return state.copy({ todos }); }
sendCommandToServer() { return api.addTodo(this.todo); }
reloadFromServer() { return api.loadTodos(); }
}
-
OptimisticCommand: for commands like "add a todo" or "send a message". It changes the state right away, sends the command to the server, and puts the old state back if the command fails. It can also reload the value from the server, with
reloadFromServer(). -
OptimisticSync: for values that change fast, like a "like" button. Every click changes the state right away, but only one request at a time goes to the server, with the latest value.
-
OptimisticSyncWithPush: the same, for apps where the server also pushes changes (WebSockets, Firebase), and more than one device can change the same value.
One mutation at a time
In TanStack Query, mutations with the same scope id run one after the other:
useMutation({ mutationFn: (todo: Todo) => api.saveTodo(todo), scope: { id: 'todos' } });
In Kiss, use the sequential feature. The actions of the class run one after the other, in the order they were dispatched:
class SaveTodo extends Action {
sequential = true;
async reduce() { ... }
}
Invalidation
In TanStack Query, after a mutation, you call invalidateQueries to mark the related queries
as stale, so they fetch again. This is needed because the library owns the cache, and the
mutation doesn't know what's in it:
useMutation({
mutationFn: (text: string) => api.saveText(text),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['texts'] }),
});
Kiss doesn't need this. The action that saves already knows what to reload, and does it right away:
class SaveText extends Action {
constructor(readonly text: string) { super(); }
async reduce() {
await api.saveText(this.text);
this.dispatch(new LoadTexts());
return null;
}
}
If LoadTexts uses fresh, give it a force parameter, and override ignoreFresh to return
it (see Forcing the action to run).
The optimistic classes do it for you: an OptimisticCommand reloads the value with
reloadFromServer(), an OptimisticSync can reload in onFinish, and an
OptimisticSyncWithPush gets the changes pushed by the server, so there is nothing to reload.
Changing the data by hand
TanStack Query lets you change the cache directly, with setQueryData:
queryClient.setQueryData<Todo[]>(['todos'], (old) => old?.filter((todo) => todo.id !== id));
In Kiss, that's a sync action, which changes the state and nothing else:
class RemoveTodo extends Action {
constructor(readonly id: string) { super(); }
reduce() {
return (state: State) => state.copy({ todos: state.todos.filter((todo) => todo.id !== this.id) });
}
}
A global loading indicator
TanStack Query has useIsFetching and useIsMutating, for a top progress bar that shows while
anything is loading:
const isFetching = useIsFetching();
In Kiss, list the actions the bar should show:
const isLoading = useIsWaiting(LoadUser) || useIsWaiting(LoadPosts);
This is on purpose. In TanStack Query, the library owns every request, so counting them makes
sense. In Kiss, actions are not only requests: a global count would mix the actions that load
data with the ones that save, poll, sync, or just change the state, and the bar would blink
for things the user doesn't care about. For the rare case that really needs the global count,
store.actionsInProgress() returns all the actions in progress.
Server-side rendering
TanStack Query renders on the server by prefetching the queries, serializing the cache with
dehydrate, and giving it to the client with HydrationBoundary:
export default async function UserPage({ params }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['user', params.id], queryFn: () => api.loadUser(params.id) });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserView />
</HydrationBoundary>
);
}
In Kiss, you create one store per request, and render with the state. The state travels to
the client as JSON, and the client creates its store with it. In Next.js, each route loads its
data on the server, and puts it in the store with StoreSlice:
export default async function UserPage({ params }) {
const user = await api.loadUser(params.id);
return (
<StoreSlice state={{ user }} key={`${user.id}:${user.updatedAt}`}>
<UserView />
</StoreSlice>
);
}
Persistence
TanStack Query can persist the cache with persistQueryClient:
const persister = createSyncStoragePersister({ storage: window.localStorage });
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
<App />
</PersistQueryClientProvider>
Kiss persists the state with a persistor, which saves the state as it changes, and loads it when the app starts. It works with plain objects and with class instances:
const store = createStore<State>({
initialState: State.initialState,
persistor: new ClassPersistor(...),
});
Clearing the data
TanStack Query clears the whole cache with queryClient.clear(), or some queries with
removeQueries. Unused data is also forgotten by itself, after gcTime:
queryClient.clear();
queryClient.removeQueries({ queryKey: ['user'] });
In Kiss, the state is explicit, so you decide what to clear and when. To clear everything when
the user logs out, call store.logOut(), which resets the state and forgets all the fresh
periods, failures and polling. To clear a part of the state, dispatch a sync action:
await store.logOut({ initialState: State.initialState });
class ClearUser extends Action {
reduce() { return (state: State) => state.copy({ user: null }); }
}
There is no gcTime, because nothing is kept apart from what you put in the state. To free
memory when a screen is left, dispatch a clean-up action in onUnmount.
Devtools
TanStack Query has a devtools panel that shows the queries and their status. Kiss has no UI
for this, but you can log every action and every state change, with actionObserver and
stateObserver in createStore:
const store = createStore<State>({
initialState: State.initialState,
actionObserver: (action, dispatchCount, ini) => console.log(ini ? 'start' : 'end', action),
stateObserver: (action, prevState, newState, error) => console.log(action, newState),
});
Testing
TanStack Query ties the component to the query. So, testing an app that uses it usually means testing UI components, which is more work.
Kiss doesn't tie the component to the action. You can test the actions and reducers directly, without any UI. Kiss also has many features to help you test your actions:
// Start with some IBM stocks.
const store = new Store<State>({ initialState: new State({ portfolio: ['IBM'] }) });
// Buy Tesla stocks.
await store.dispatchAndWait(new BuyAction('TSLA'));
// Check we now have IBM and Tesla.
expect(store.state.portfolio).toEqual(['IBM', 'TSLA']);
To simulate the app going offline or to another tab, TanStack Query has:
onlineManager.setOnline(false);
focusManager.setFocused(false);
Kiss has the same in the store:
store.setOnline(false); // Actions with checkInternet now fail with "No Internet".
store.setFocused(false); // The user went to another tab.
store.setFocused(true); // The user came back: the onFocus callbacks run.