Skip to main content

Overview

State management is crucial for building interactive React applications. This guide covers three essential patterns found in the React Mini Projects:
  • useState for reactive component state
  • useRef for persistent values without re-renders
  • localStorage for data persistence across sessions

useState: Basic State Management

Simple State Updates

The Traffic Light project demonstrates basic state management with useState:
TrafficLight.tsx
import { useState } from "react";

type TrafficLightColor = "red" | "yellow" | "green";

export const TrafficLight = () => {
  const [light, setLight] = useState<TrafficLightColor>("red");

  const handleColorChange = (color: TrafficLightColor) => {
    setLight((prev) => {
      console.log({ prev });
      return color;
    });
  };

  return (
    <div>
      <div className={`w-32 h-32 ${light === "red" ? "bg-red-500" : "bg-gray-500"}`} />
      <button onClick={() => handleColorChange("red")}>Red</button>
    </div>
  );
};
The functional update form setLight((prev) => ...) is useful when you need access to the previous state value.

Multiple State Variables

The GIFs App manages multiple related state values:
useGifs.tsx
import { useState } from "react";

export const useGifs = () => {
  const [gifs, setGifs] = useState<Gif[]>([]);
  const [previousTerms, setPreviousTerms] = useState<string[]>([]);

  const handleSearch = async (query: string) => {
    query = query.trim().toLowerCase();
    if (query.length === 0) return;

    if (previousTerms.includes(query)) return;

    // Keep only the 7 most recent searches
    setPreviousTerms([query, ...previousTerms].splice(0, 7));

    const gifs = await getGifsByQuery(query);
    setGifs(gifs);
  };

  return { gifs, handleSearch, previousTerms };
};
Keep related state together in the same useState call, or separate them if they update independently.

useRef: Non-Reactive Persistence

Caching with useRef

The GIFs App uses useRef to implement a cache that persists across renders without causing re-renders:
useGifs.tsx
import { useRef, useState } from "react";

export const useGifs = () => {
  const [gifs, setGifs] = useState<Gif[]>([]);
  const gifsCache = useRef<Record<string, Gif[]>>({});

  const handleTermClicked = async (term: string) => {
    // Check cache first
    if (gifsCache.current[term]) {
      setGifs(gifsCache.current[term]);
      return;
    }
    
    // Fetch if not cached
    const gifs = await getGifsByQuery(term);
    setGifs(gifs);
  };

  const handleSearch = async (query: string) => {
    const gifs = await getGifsByQuery(query);
    setGifs(gifs);
    
    // Store in cache
    gifsCache.current[query] = gifs;
  };

  return { gifs, handleSearch, handleTermClicked };
};
Using useRef for the cache prevents unnecessary re-renders. When the cache updates, we don’t need to re-render the component because the cache itself isn’t displayed. Only when we update the gifs state (which IS displayed) should the component re-render.

useState vs useRef Comparison

const [cache, setCache] = useState({});

// Every cache update triggers a re-render
setCache({ ...cache, [key]: value });

localStorage: Cross-Session Persistence

Saving User Preferences

The Weather Finder app remembers the last searched city using localStorage:
useWeather.ts
import { useState, useEffect, useCallback } from 'react';

const LAST_CITY_KEY = 'weather-finder:last-city';

export function useWeather() {
  // Initialize from localStorage
  const [savedCity] = useState<string>(() => 
    localStorage.getItem(LAST_CITY_KEY) ?? ''
  );

  const [state, setState] = useState<WeatherState>({
    status: 'idle',
    data: null,
    error: null,
  });

  const search = useCallback(async (city: string) => {
    setState({ status: 'loading', data: null, error: null });

    try {
      const { name, latitude, longitude } = await geocodeCity(city);
      const { current, daily } = await fetchWeather(latitude, longitude);

      // Save to localStorage on successful search
      localStorage.setItem(LAST_CITY_KEY, city);

      setState({
        status: 'success',
        data: { cityName: name, current, daily },
        error: null,
      });
    } catch (err) {
      setState({
        status: 'error',
        data: null,
        error: err instanceof Error ? err.message : 'Unknown error',
      });
    }
  }, []);

  // Auto-search the last city on mount
  useEffect(() => {
    const lastCity = localStorage.getItem(LAST_CITY_KEY);
    if (lastCity) search(lastCity);
  }, [search]);

  return { ...state, search, savedCity };
}
Use lazy initialization useState(() => localStorage.getItem(...)) to read from localStorage only once during the initial render.

localStorage Best Practices

// Lazy initialization (recommended)
const [value, setValue] = useState(() => 
  localStorage.getItem('key') ?? 'default'
);

// Alternative: useEffect
useEffect(() => {
  const saved = localStorage.getItem('key');
  if (saved) setValue(saved);
}, []);

Complex State Management

Combining Multiple Patterns

The Weather Finder combines all three patterns:
useWeather.ts
export function useWeather() {
  // useState for component state
  const [state, setState] = useState<WeatherState>({
    status: 'idle',
    data: null,
    error: null,
  });

  // localStorage for persistence
  const [savedCity] = useState(() => 
    localStorage.getItem(LAST_CITY_KEY) ?? ''
  );

  // useCallback for stable function reference
  const search = useCallback(async (city: string) => {
    setState({ status: 'loading', data: null, error: null });
    
    try {
      const data = await fetchWeather(city);
      localStorage.setItem(LAST_CITY_KEY, city);
      setState({ status: 'success', data, error: null });
    } catch (err) {
      setState({ status: 'error', data: null, error: err.message });
    }
  }, []);

  return { ...state, search, savedCity };
}

Custom Hooks Pattern

Extracting Reusable Logic

The Counter hook demonstrates extracting state logic into a custom hook:
useCounter.tsx
import { useState } from "react";

export const useCounter = (initialValue: number = 10) => {
  const [counter, setCounter] = useState(initialValue);
  
  const handleAdd = () => {
    setCounter(counter + 1);
  };
  
  const handleSubtract = () => {
    setCounter((prevState) => prevState - 1);
  };

  const handleReset = () => {
    setCounter(initialValue);
  };
  
  return {
    counter,
    handleAdd,
    handleSubtract,
    handleReset,
  };
};

Using the Custom Hook

MyComponent.tsx
import { useCounter } from './hooks/useCounter';

export const MyCounterApp = () => {
  const { counter, handleAdd, handleSubtract, handleReset } = useCounter(5);

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={handleAdd}>+</button>
      <button onClick={handleSubtract}>-</button>
      <button onClick={handleReset}>Reset</button>
    </div>
  );
};
Custom hooks make your state logic reusable across multiple components and easier to test in isolation.

Key Takeaways

  1. useState: Use for values that should trigger re-renders when they change
  2. useRef: Use for values that need to persist but shouldn’t trigger re-renders (caching, DOM refs, timers)
  3. localStorage: Use for data that should persist across browser sessions
  4. Custom hooks: Extract and reuse stateful logic across components
  5. Functional updates: Use setState(prev => ...) when new state depends on previous state

Component Patterns

Learn about component composition and prop patterns

API Integration

Discover patterns for fetching and managing API data

Build docs developers (and LLMs) love