Where each state lives
Almost every hard frontend bug is the same thing: two sources of truth for the
same data. The list came from the server and also lives in a useState; the
filter is in the URL and also in a store; the total is a field and also a sum. One
of the two always goes stale.
The cure isn't a better library. It's deciding, for each piece of state, one place where it lives.
Five places, five questions
flowchart TD
Q1{"Can it be derived<br/>from what already exists?"} -->|yes| DER["Not state.<br/>Compute it in render."]
Q1 -->|no| Q2{"Did it come<br/>from the server?"}
Q2 -->|yes| SRV["TanStack Query"]
Q2 -->|no| Q3{"Must it survive<br/>reload / a shared link?"}
Q3 -->|yes| URL["URL (searchParams)"]
Q3 -->|no| Q4{"Is it a form field?"}
Q4 -->|yes| FORM["react-hook-form"]
Q4 -->|no| Q5{"Does more than one<br/>screen need it?"}
Q5 -->|yes| GLB["Zustand store"]
Q5 -->|no| LOC["local useState"]
| Kind of state | Lives in | Example |
|---|---|---|
| Derived | nothing — compute it | total, isValid, count, label |
| Server | TanStack Query | order list, profile |
| Navigation | URL (useSearchParams) |
page, filter, tab, search term |
| Form | useZodForm |
fields, errors, isSubmitting |
| Global client | createStore |
session, theme, cart, preference |
| Local UI | useState |
modal open, hover, accordion |
| Persisted offline | createOfflineStore |
outbox, Dexie cache |
1. Derived is not state
The most common mistake, and the cheapest to fix:
const [orders, setOrders] = useState<Order[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(orders.reduce((sum, o) => sum + o.totalCents, 0));
}, [orders]);
Two sources, a syncing useEffect, and a render window where total is wrong.
const [orders, setOrders] = useState<Order[]>([]);
const total = orders.reduce((sum, o) => sum + o.totalCents, 0);
One source. Impossible to diverge.
A useEffect that only calls setState is almost always derived state
Search your app for that pattern — it's the best effort-to-payoff find there
is. And no, you don't need useMemo for this: memoize when you measure the
computation to be expensive, not by reflex.
2. Server data does not live in useState
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let alive = true;
listOrders(page)
.then((data) => alive && setOrders(data))
.catch((e) => alive && setError(e))
.finally(() => alive && setIsLoading(false));
return () => {
alive = false;
};
}, [page]);
const { data: orders = [], isLoading, error } = useOrders(page);
The first has 3 states to keep in sync, an alive flag to avoid setState after
unmount, zero cache, zero dedupe across components, zero revalidation when you come
back to the tab. The second has all of it — because server state is cache, and
cache is a solved problem.
What Query already does for you
Dedupe of simultaneous requests, staleTime, refetch on focus/reconnect, retry,
optimistic mutations, invalidation by key, offline persistence. Each of those is
a bug you don't write. See Query.
3. Navigation state lives in the URL
Filter, page, sorting, active tab, search term: if the user might want to send the link to someone, the state belongs to the URL.
import { useSearchParams } from "tempest-react-sdk";
/**
* Orders screen. Page and status live in the query string, so the browser back
* button, refresh and a shared link all restore the same view.
*/
export function Orders() {
const [params, setParams] = useSearchParams();
const page = Number(params.get("page") ?? 1);
const status = params.get("status") ?? "all";
const setStatus = (next: string) => {
setParams({ status: next, page: "1" });
};
const { data = [], isLoading } = useOrders({ page, status });
// …
}
Notice: no useState. No useEffect syncing URL with state. The URL is the
state.
Mirroring the URL into a useState is the classic mistake
const [page, setPage] = useState(Number(params.get("page"))) creates the
second source of truth immediately: the back button changes the URL and doesn't
change the useState. Read from the URL on every render — it's cheap and always
correct.
4. Forms have their own owner
A field controlled by useState means onChange re-rendering the whole screen on
every keystroke, hand-written validation, and errors out of sync:
import { FormField, Input, useZodForm } from "tempest-react-sdk";
import { z } from "zod";
const schema = z.object({
name: z.string().min(3, "At least 3 characters"),
email: z.string().email("Invalid email"),
});
/** Customer form: one schema drives types, validation and error messages. */
export function CustomerForm({ onSave }: { onSave: (v: z.infer<typeof schema>) => void }) {
const form = useZodForm(schema);
return (
<form onSubmit={form.handleSubmit(onSave)}>
<FormField name="name" label="Name" control={form.control}>
<Input />
</FormField>
<FormField name="email" label="Email" control={form.control}>
<Input type="email" />
</FormField>
<button type="submit" disabled={form.formState.isSubmitting}>
Save
</button>
</form>
);
}
One schema produces three things: the TypeScript type, the runtime validation and the error message. Details in Forms (zod).
5. Global client state: a store, and only what's needed
A store is for state that does not come from the server and that several screens need: session, theme, cart, preferences, a multi-step wizard.
// src/stores/cart.ts
import { createSelectors, createStore } from "tempest-react-sdk";
interface CartState {
items: string[];
add: (id: string) => void;
clear: () => void;
}
/**
* Cart slice. Only `items` is persisted — the actions are recreated on load,
* so writing them to storage would just bloat the payload.
*/
export const useCart = createSelectors(
createStore<CartState>(
(set) => ({
items: [],
add: (id) => set((s) => ({ items: [...s.items, id] })),
clear: () => set({ items: [] }),
}),
{ persist: { name: "cart", partialize: (s) => ({ items: s.items }) } },
),
);
createSelectors generates useCart.use.items() — the component subscribes to
one field instead of the whole store, and doesn't re-render when another field
changes.
A store is not a server cache
Putting the order list in a Zustand store re-implements by hand everything Query already does — without invalidation, revalidation or dedupe. The symptom is always the same: the screen shows stale data after a POST.
6. Local UI state: useState, guilt-free
Modal open, hovered item, expanded accordion, carousel index. None of that needs a library:
const [isOpen, setIsOpen] = useState(false);
If only one component and its direct children need it, useState is the right
answer. Lifting it into a global store is free coupling.
The 10-second test
Look at a useState in your app and ask, in this order:
- Can I compute this? → delete it.
- Did it come from the network? → Query.
- Does it make sense in the link? → URL.
- Is it a field? → form.
- Does another screen need it? → store.
- None of the above? → leave it where it is. It's right.
Recap
- A state bug is almost always two sources of truth.
- Derived is not state: compute in render, don't sync with
useEffect. - Server → Query. Navigation → URL. Field → form. Shared → store.
Only here →
useState. createSelectorsmakes a component subscribe to one field, not the whole store.- A store never replaces a server cache.
Next: Thinking in components.