Choosing Where State Lives

Local, lifted, server cache or global store: a decision table, and why fetched data should stay in the query cache.

Most state bugs are placement bugs. The value is right but it lives in the wrong place, so two screens disagree or a change refuses to propagate. Four homes are worth knowing, and the correct one is nearly always the narrowest that works.

KindLives inUse whenTool
LocalOne componentNothing outside it reads the valueuseState, useReducer
LiftedNearest common parentTwo siblings need the same valueProps from the parent
Server cacheThe query cacheThe value's real home is an APIReact Query
Global clientA store or contextUnrelated screens read it and the server never sees itZustand, context

Start narrow

Put state in the component that uses it. When a sibling needs it, lift it to the nearest parent they share and pass it down. Only go global when unrelated screens read it and there is no parent worth threading through.

Server data is not global state

Data owned by an API belongs in the query cache. A copy in a store has no notion of staleness, no refetch, no request de-duplication and no invalidation after a mutation, so you end up rebuilding a cache badly and showing two different answers on two screens. Let the cache be the single source of truth and keep in the store only what the server never sees.

Before adding state at all, check that it is state. Anything you can compute from values you already have — a filtered list, a total, a validity flag — should be computed during render.

Example

Example · tsx
// One screen using three of the four homes: a filter box at the top and a scrolling list
// of article titles under it, each row tighter when compact mode is on.
import { useState } from "react";
import { FlatList, StyleSheet, Text, TextInput, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { create } from "zustand";

type Article = { id: string; title: string };

// Global client state: a preference many screens read, which no API owns.
const useSettingsStore = create<{ compact: boolean }>()(() => ({ compact: true }));

async function fetchArticles(): Promise<Article[]> {
  const response = await fetch("https://api.example.com/articles");
  return (await response.json()) as Article[];
}

export default function ArticlesScreen() {
  // Local: nothing outside this screen cares what is in the box.
  const [query, setQuery] = useState("");
  // Server cache: the list belongs to the API, so the cache holds it and nothing copies it.
  const { data = [] } = useQuery({ queryKey: ["articles"], queryFn: fetchArticles });
  const compact = useSettingsStore((state) => state.compact);
  // Derived, so not state at all: recomputed on each render from the two values above.
  const visible = data.filter((article) => article.title.includes(query));

  return (
    <View style={styles.screen}>
      <TextInput value={query} onChangeText={setQuery} placeholder="Filter" style={styles.input} />
      <FlatList
        data={visible}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <Text style={compact ? styles.compactRow : styles.row}>{item.title}</Text>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 12 },
  input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 8, padding: 10 },
  row: { paddingVertical: 12 },
  compactRow: { paddingVertical: 6 },
});

When to use it

  • Decide whether a filter belongs in the screen, in its parent, or in a store before building the screen.
  • Remove a store that duplicates fetched data and move its readers onto the query cache.
  • Work out which of a form's values are state and which are derived from the fields already held.
  • Settle a review argument about putting the current user in context, in a store, or in the cache.

More examples

Lifting state to the nearest common parent

On screen: a minus, a number and a plus in a row, with a total beneath; each tap changes both at once. This is the step most people skip on the way to a global store. Two siblings needed one number, so the number moved up one level and nothing else in the app knows about it.

Example · tsx
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

type StepperProps = { value: number; onChange: (next: number) => void };

// The child holds nothing. It is told the value and reports changes upward.
function Stepper({ value, onChange }: StepperProps) {
  return (
    <View style={styles.row}>
      <Pressable onPress={() => onChange(Math.max(1, value - 1))}>
        <Text style={styles.key}>-</Text>
      </Pressable>
      <Text style={styles.value}>{value}</Text>
      <Pressable onPress={() => onChange(value + 1)}>
        <Text style={styles.key}>+</Text>
      </Pressable>
    </View>
  );
}

function Total({ quantity }: { quantity: number }) {
  return <Text>{`Total: ${quantity * 3} USD`}</Text>;
}

// Lifted exactly as far as it has to go: the nearest parent both children sit under.
export default function OrderScreen() {
  const [quantity, setQuantity] = useState(1);
  return (
    <View style={styles.screen}>
      <Stepper value={quantity} onChange={setQuantity} />
      <Total quantity={quantity} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
  row: { flexDirection: "row", alignItems: "center", gap: 20 }, // flexDirection defaults to column
  key: { fontSize: 28 },
  value: { fontSize: 20, fontWeight: "600" },
});

The copy-into-a-store anti-pattern

No UI; two versions of the same hook. The first is the shape that appears whenever a team adds a store before adding a cache, and it costs an extra render, a stale copy and a loading state that no longer matches the data. The second is the whole replacement: the cache is already global, so there is nothing left for the store to do.

Example · tsx
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { create } from "zustand";

type Profile = { id: string; name: string };

async function fetchProfile(id: string): Promise<Profile> {
  const response = await fetch(`https://api.example.com/profiles/${id}`);
  return (await response.json()) as Profile;
}

const useProfileStore = create<{
  profile: Profile | null;
  setProfile: (profile: Profile) => void;
}>()((set) => ({ profile: null, setProfile: (profile) => set({ profile }) }));

// WRONG: the cache fetches, then an effect copies the result into a store. There are now two
// copies. After a mutation the cache updates and the store does not, so screens disagree.
export function useProfileCopied(id: string): Profile | null {
  const { data } = useQuery({ queryKey: ["profile", id], queryFn: () => fetchProfile(id) });
  const setProfile = useProfileStore((state) => state.setProfile);
  useEffect(() => {
    if (data !== undefined) {
      setProfile(data);
    }
  }, [data, setProfile]);
  return useProfileStore((state) => state.profile);
}

// RIGHT: read from the cache. One copy, with staleness, refetching and invalidation handled.
export function useProfile(id: string) {
  return useQuery({ queryKey: ["profile", id], queryFn: () => fetchProfile(id) });
}

Derived values are not state

A plain function and its results. The habit it teaches is the cheapest state decision available: before choosing between local, lifted, cached and global, check whether the value can simply be calculated during render. An allDone flag stored next to the list is a bug waiting for the one code path that forgets to update it.

Example · tsx
type Item = { id: string; done: boolean };

// Everything here is computed from one array. None of it needs its own useState: a second
// field would only give you something that can fall out of step with the first.
export function summarise(items: Item[]): { total: number; done: number; allDone: boolean } {
  const done = items.filter((item) => item.done).length;
  return { total: items.length, done, allDone: items.length > 0 && done === items.length };
}

console.log(summarise([{ id: "a", done: true }, { id: "b", done: false }]));
// Output: { total: 2, done: 1, allDone: false }

console.log(summarise([]));
// Output: { total: 0, done: 0, allDone: false }

// In a component: const { done, allDone } = summarise(items);
// No effect, no extra state, nothing to keep in sync.

Discussion

  • Be the first to comment on this lesson.