---
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