7 min read
State & Data
Server state belongs in a cache, not in Redux — plus where Zustand fits, and how to make optimistic updates that roll back correctly.
Principles
Server state and client state are different problems
Server state is a cache of something you don't own: it goes stale, needs revalidation, and can fail. Client state is yours and is always correct. Storing the first in Redux means hand-writing caching, deduping, and retry logic that React Query already solved.
Derive state instead of storing it
Every stored value that could have been computed is a value that can go out of sync. If `total` is `items` reduced, compute it — a `total` in state is a bug waiting for someone to forget an update.
The query key is the cache contract
Keys must include every input that changes the result. A key missing a filter parameter serves one filter's data to another, which presents as an impossible bug report.
Optimistic updates need a real rollback
Apply, snapshot, and restore the snapshot on error. An optimistic update without rollback leaves the UI confidently displaying something that never happened on the server.
Match the tool to the scope
`useState` for local, Zustand for cross-screen client state, React Query for anything from a server. Redux Toolkit earns its ceremony in genuinely complex shared workflows — reaching for it by default is how you get 200 lines of boilerplate around a boolean.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Server state in the wrong place
Avoid
// A thunk, three action types, a reducer case each,
// and a slice of state — to fetch a list.
const slice = createSlice({
name: "products",
initialState: { items: [], loading: false, error: null },
extraReducers: (b) => {
b.addCase(fetchProducts.pending, (s) => { s.loading = true; });
b.addCase(fetchProducts.fulfilled, (s, a) => {
s.loading = false; s.items = a.payload;
});
b.addCase(fetchProducts.rejected, (s, a) => {
s.loading = false; s.error = a.error;
});
},
});
// Still no caching, deduping, retry, or refetch-on-focus.Substantial ceremony that reimplements a fraction of what a query cache gives you for free.
Prefer
export function useProducts(filter: Filter) {
return useQuery({
// Every input that changes the result is in the key.
queryKey: ["products", filter.category, filter.sort],
queryFn: () => fetchProducts(filter),
staleTime: 60_000,
});
}Caching, deduping, retries, and background revalidation included. The key names the data precisely.
Optimistic update with rollback
Avoid
useMutation({
mutationFn: toggleFavorite,
onMutate: (id) => {
// Applied optimistically, with no way back.
queryClient.setQueryData(["favorites"], (old) => [...old, id]);
},
// Request fails → UI still shows a favourite that doesn't exist.
});The optimistic write is permanent. A failed request silently desynchronizes the client from the server.
Prefer
useMutation({
mutationFn: toggleFavorite,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["favorites"] });
const previous = queryClient.getQueryData(["favorites"]);
queryClient.setQueryData(["favorites"], (old) => [...old, id]);
return { previous }; // snapshot travels to onError
},
onError: (_err, _id, context) => {
queryClient.setQueryData(["favorites"], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});Cancel, snapshot, restore on failure, reconcile with the server either way.
Storing what should be derived
Avoid
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
function add(item: Item) {
setItems((prev) => [...prev, item]);
setTotal((prev) => prev + item.price); // forget this once, ship a bug
}Two sources of truth for one fact. They will disagree the first time someone adds a removal path.
Prefer
const [items, setItems] = useState<Item[]>([]);
// Cannot disagree with `items`, because it is `items`.
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items],
);One source of truth. Every mutation path stays correct for free.
Review checklist
What I look for when reviewing a pull request that touches this area.
- No server data lives in Redux or Zustand.
- Query keys include every parameter that changes the response.
- Optimistic updates cancel, snapshot, and roll back on error.
- Nothing is stored that could be derived.
- `staleTime` is a deliberate choice per query, not left at the default.
- The state tool matches the scope: local, shared-client, or server.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-state-and-data/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-state-and-data
description: >-
Choose and apply state management in React and React Native: React Query
for server state, Zustand or Redux Toolkit for client state, derived
values, query key design, and safe optimistic updates. Use when adding
data fetching or state.
argument-hint: "[feature]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# State & Data
Apply when introducing state or data fetching. Start by asking whether the data comes from a server — if it does, it belongs in a query cache, not a client store.
## Rules
1. **Server state and client state are different problems** — Server state is a cache of something you don't own: it goes stale, needs revalidation, and can fail. Client state is yours and is always correct. Storing the first in Redux means hand-writing caching, deduping, and retry logic that React Query already solved.
2. **Derive state instead of storing it** — Every stored value that could have been computed is a value that can go out of sync. If `total` is `items` reduced, compute it — a `total` in state is a bug waiting for someone to forget an update.
3. **The query key is the cache contract** — Keys must include every input that changes the result. A key missing a filter parameter serves one filter's data to another, which presents as an impossible bug report.
4. **Optimistic updates need a real rollback** — Apply, snapshot, and restore the snapshot on error. An optimistic update without rollback leaves the UI confidently displaying something that never happened on the server.
5. **Match the tool to the scope** — `useState` for local, Zustand for cross-screen client state, React Query for anything from a server. Redux Toolkit earns its ceremony in genuinely complex shared workflows — reaching for it by default is how you get 200 lines of boilerplate around a boolean.
## Patterns
### Server state in the wrong place
**Avoid** — Substantial ceremony that reimplements a fraction of what a query cache gives you for free.
```ts
// A thunk, three action types, a reducer case each,
// and a slice of state — to fetch a list.
const slice = createSlice({
name: "products",
initialState: { items: [], loading: false, error: null },
extraReducers: (b) => {
b.addCase(fetchProducts.pending, (s) => { s.loading = true; });
b.addCase(fetchProducts.fulfilled, (s, a) => {
s.loading = false; s.items = a.payload;
});
b.addCase(fetchProducts.rejected, (s, a) => {
s.loading = false; s.error = a.error;
});
},
});
// Still no caching, deduping, retry, or refetch-on-focus.
```
**Prefer** — Caching, deduping, retries, and background revalidation included. The key names the data precisely.
```ts
export function useProducts(filter: Filter) {
return useQuery({
// Every input that changes the result is in the key.
queryKey: ["products", filter.category, filter.sort],
queryFn: () => fetchProducts(filter),
staleTime: 60_000,
});
}
```
### Optimistic update with rollback
**Avoid** — The optimistic write is permanent. A failed request silently desynchronizes the client from the server.
```ts
useMutation({
mutationFn: toggleFavorite,
onMutate: (id) => {
// Applied optimistically, with no way back.
queryClient.setQueryData(["favorites"], (old) => [...old, id]);
},
// Request fails → UI still shows a favourite that doesn't exist.
});
```
**Prefer** — Cancel, snapshot, restore on failure, reconcile with the server either way.
```ts
useMutation({
mutationFn: toggleFavorite,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["favorites"] });
const previous = queryClient.getQueryData(["favorites"]);
queryClient.setQueryData(["favorites"], (old) => [...old, id]);
return { previous }; // snapshot travels to onError
},
onError: (_err, _id, context) => {
queryClient.setQueryData(["favorites"], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});
```
### Storing what should be derived
**Avoid** — Two sources of truth for one fact. They will disagree the first time someone adds a removal path.
```ts
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
function add(item: Item) {
setItems((prev) => [...prev, item]);
setTotal((prev) => prev + item.price); // forget this once, ship a bug
}
```
**Prefer** — One source of truth. Every mutation path stays correct for free.
```ts
const [items, setItems] = useState<Item[]>([]);
// Cannot disagree with `items`, because it is `items`.
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items],
);
```
## Review checklist
- [ ] No server data lives in Redux or Zustand.
- [ ] Query keys include every parameter that changes the response.
- [ ] Optimistic updates cancel, snapshot, and roll back on error.
- [ ] Nothing is stored that could be derived.
- [ ] `staleTime` is a deliberate choice per query, not left at the default.
- [ ] The state tool matches the scope: local, shared-client, or server.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim