Steven's Knowledge
Troubleshooting

State Management

State management pitfalls in frontend applications and systematic solutions

State Management Troubleshooting

State management is the backbone of interactive frontend applications. Mismanaged state leads to UI inconsistencies, performance degradation, and hard-to-debug behaviors.


1. Prop Drilling — Deep Component Coupling

Problem

Passing state through many layers of components that don't use it:

// 5 levels deep just to pass `theme`
<App theme={theme}>
  <Layout theme={theme}>
    <Sidebar theme={theme}>
      <Navigation theme={theme}>
        <NavItem theme={theme} />  {/* Finally used here */}
      </Navigation>
    </Sidebar>
  </Layout>
</App>
  • Intermediate components become coupled to data they don't use
  • Refactoring is painful — changing prop names requires updating every layer
  • Difficult to trace data flow

Solution

React Context for cross-cutting concerns:

const ThemeContext = createContext<Theme | null>(null);

function useTheme() {
  const theme = useContext(ThemeContext);
  if (!theme) throw new Error('useTheme must be used within ThemeProvider');
  return theme;
}

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// NavItem directly accesses theme — no prop drilling
function NavItem() {
  const { theme } = useTheme();
  return <div className={theme}>...</div>;
}

Component Composition pattern:

// Instead of drilling props, compose components
function App() {
  const [user, setUser] = useState<User | null>(null);

  return (
    <Layout>
      <Sidebar>
        <UserProfile user={user} />  {/* Pass directly to consumer */}
      </Sidebar>
    </Layout>
  );
}

// Layout and Sidebar don't need to know about `user`
function Layout({ children }: { children: React.ReactNode }) {
  return <div className="layout">{children}</div>;
}

2. Unnecessary Re-renders from State Changes

Problem

function App() {
  const [user, setUser] = useState({ name: 'Alice', preferences: { theme: 'dark' } });

  const updateTheme = (theme: string) => {
    // Mutating state directly — React won't detect change
    user.preferences.theme = theme;
    setUser(user); // Same reference — no re-render
  };

  return <Settings user={user} onThemeChange={updateTheme} />;
}

Or conversely, too many re-renders:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  const [filters, setFilters] = useState({});
  // Every state update re-renders the entire component tree
}

Root Cause

  • Direct state mutation (React relies on reference equality)
  • Too much state in a single component
  • Context value changes causing all consumers to re-render

Solution

Immutable state updates:

const updateTheme = (theme: string) => {
  setUser(prev => ({
    ...prev,
    preferences: { ...prev.preferences, theme },
  }));
};

Use Immer for complex nested updates:

import { produce } from 'immer';

const updateTheme = (theme: string) => {
  setUser(produce(draft => {
    draft.preferences.theme = theme; // Mutate draft safely
  }));
};

Split state by update frequency:

function SearchPage() {
  // UI state — changes frequently
  const [query, setQuery] = useState('');

  // Data state — changes less often, can be separated
  const { results, isLoading, error } = useSearchResults(query);

  // Pagination — independent concern
  const { page, nextPage, prevPage } = usePagination();

  return (/* ... */);
}

Selective context subscriptions with useSyncExternalStore or state libraries:

import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  theme: 'light',
  setTheme: (theme) => set({ theme }),
  setUser: (user) => set({ user }),
}));

// Only re-renders when `theme` changes
function ThemeToggle() {
  const theme = useStore((state) => state.theme);
  const setTheme = useStore((state) => state.setTheme);
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}

3. Stale Closures in Hooks

Problem

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);     // Always logs 0 — stale closure
      setCount(count + 1);    // Always sets to 1 — stale closure
    }, 1000);
    return () => clearInterval(id);
  }, []); // Empty deps — captures initial `count` forever

  return <div>{count}</div>;
}

Root Cause

JavaScript closures capture variables at creation time. When useEffect runs with [] deps, the callback captures the initial count = 0 forever.

Solution

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1); // ✓ Functional update — no stale closure
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

For accessing latest value without re-subscribing:

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

function EventHandler() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);

  useEffect(() => {
    const handler = () => {
      console.log(countRef.current); // Always latest value
    };
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []); // Safe — ref is stable
}

4. Race Conditions in Async State Updates

Problem

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data)); // May set stale data if userId changed
  }, [userId]);
}

If userId changes rapidly (e.g., 123), responses may arrive out of order, displaying user 1's data when user 3 is expected.

Solution

AbortController for cancellation:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controller.abort(); // Cancel on cleanup
  }, [userId]);
}

Using TanStack Query (recommended for data fetching):

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
    staleTime: 5 * 60 * 1000,
  });
}

5. Global State Overuse — "Everything in Redux" Anti-Pattern

Problem

Putting all state in a global store:

// Redux store with EVERYTHING
const store = {
  user: { ... },
  theme: 'dark',
  modalOpen: false,          // UI state — should be local
  formValues: { ... },       // Form state — should be local
  tooltipVisible: true,      // Ephemeral UI state
  scrollPosition: 0,         // Derived from DOM
  searchResults: [...],      // Server cache
};
  • Performance: Every state change triggers selectors across the app
  • Complexity: Actions, reducers, selectors for trivial state
  • Testing: Components can't be tested in isolation

Solution: State Classification

State TypeExamplesRecommended Tool
Local UIModal open, tooltip visible, form inputuseState, useReducer
Shared UITheme, locale, sidebar collapsedContext, Zustand, Jotai
Server CacheAPI responses, paginated dataTanStack Query, SWR
URL StateSearch params, filters, paginationuseSearchParams, nuqs
Form StateValidation, dirty fields, submit statusReact Hook Form, Formik
Global AppAuth state, feature flags, permissionsZustand, Redux Toolkit
function SearchPage() {
  // URL state
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') ?? '';

  // Server cache
  const { data, isLoading } = useQuery({
    queryKey: ['search', query],
    queryFn: () => searchAPI(query),
  });

  // Local UI state
  const [isFilterOpen, setFilterOpen] = useState(false);

  // Global state — only for truly global concerns
  const user = useStore(state => state.user);
}

6. Context Re-Render Cascade

Problem

const AppContext = createContext<{
  user: User;
  theme: Theme;
  notifications: Notification[];
}>({ /* ... */ });

// ANY change in user, theme, OR notifications re-renders ALL consumers
function NotificationBadge() {
  const { notifications } = useContext(AppContext); // Also re-renders on theme change!
}

Solution: Split Contexts by Update Frequency

// Separate contexts by how often they change
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('light');
const NotificationContext = createContext<Notification[]>([]);

function Providers({ children }: { children: React.ReactNode }) {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </UserProvider>
  );
}

Or use useMemo to stabilize context values:

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>('light');

  const userValue = useMemo(() => ({ user, setUser }), [user]);
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <UserContext.Provider value={userValue}>
      <ThemeContext.Provider value={themeValue}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

7. Form State Complexity

Problem

Managing form state manually leads to boilerplate and bugs:

function RegistrationForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [nameError, setNameError] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isDirty, setIsDirty] = useState(false);
  // ... 20+ state variables for a complex form
}

Solution: Use React Hook Form + Zod

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  password: z.string().min(8).regex(/[A-Z]/, 'Must contain uppercase'),
});

type FormData = z.infer<typeof schema>;

function RegistrationForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting, isDirty } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    await api.register(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit" disabled={isSubmitting}>Register</button>
    </form>
  );
}

Summary: State Management Decision Tree

Is the state used by only one component?
├─ Yes → useState / useReducer
└─ No
   ├─ Is it server data?
   │  └─ Yes → TanStack Query / SWR
   ├─ Is it URL/routing state?
   │  └─ Yes → useSearchParams / URL state library
   ├─ Is it form state?
   │  └─ Yes → React Hook Form / Formik
   ├─ Is it shared by a few nearby components?
   │  └─ Yes → Lift state up / Context
   └─ Is it truly global?
      └─ Yes → Zustand / Redux Toolkit / Jotai

On this page