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:
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:
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:
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 };
};
Why use useRef instead of useState for caching?
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
useState (causes re-renders)
useRef (no re-renders)
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:
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
Reading
Writing
JSON Data
// Lazy initialization (recommended)
const [ value , setValue ] = useState (() =>
localStorage . getItem ( 'key' ) ?? 'default'
);
// Alternative: useEffect
useEffect (() => {
const saved = localStorage . getItem ( 'key' );
if ( saved ) setValue ( saved );
}, []);
const saveToStorage = ( key : string , value : string ) => {
try {
localStorage . setItem ( key , value );
} catch ( err ) {
// Handle quota exceeded or other errors
console . error ( 'Failed to save to localStorage:' , err );
}
};
// Saving objects
const saveData = ( data : MyData ) => {
localStorage . setItem ( 'data' , JSON . stringify ( data ));
};
// Reading objects
const [ data , setData ] = useState < MyData | null >(() => {
const saved = localStorage . getItem ( 'data' );
return saved ? JSON . parse ( saved ) : null ;
});
Complex State Management
Combining Multiple Patterns
The Weather Finder combines all three patterns:
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
The Counter hook demonstrates extracting state logic into a custom hook:
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
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
useState : Use for values that should trigger re-renders when they change
useRef : Use for values that need to persist but shouldn’t trigger re-renders (caching, DOM refs, timers)
localStorage : Use for data that should persist across browser sessions
Custom hooks : Extract and reuse stateful logic across components
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