Skip to main content

Mastering React Hooks and Advanced State Management

· 8 min read
Shiaondo Orkuma
AI Engineer & Full Stack Developer @ Hash Dynamics

React Hooks revolutionized how we write React components, enabling powerful state management and side effect handling in functional components. In this comprehensive guide, I'll explore advanced hook patterns and state management strategies for building robust applications.

Advanced useState Patterns

State Reducers with useState

interface CounterState {
count: number;
step: number;
}

type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; step: number }
| { type: 'reset' };

function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.step };
case 'reset':
return { count: 0, step: 1 };
default:
return state;
}
}

function useCounterWithReducer(initialCount = 0) {
const [state, dispatch] = useReducer(counterReducer, {
count: initialCount,
step: 1
});

const increment = useCallback(() => dispatch({ type: 'increment' }), []);
const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
const setStep = useCallback((step: number) => dispatch({ type: 'setStep', step }), []);
const reset = useCallback(() => dispatch({ type: 'reset' }), []);

return {
count: state.count,
step: state.step,
increment,
decrement,
setStep,
reset
};
}

// Usage
function Counter() {
const { count, step, increment, decrement, setStep, reset } = useCounterWithReducer();

return (
<div>
<p>Count: {count}</p>
<p>Step: {step}</p>
<input
type="number"
value={step}
onChange={(e) => setStep(parseInt(e.target.value))}
/>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}

Complex State Management with useImmer

import { useImmer } from 'use-immer';

interface Todo {
id: string;
text: string;
completed: boolean;
}

interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
editingId: string | null;
}

function useTodoState() {
const [state, updateState] = useImmer<TodoState>({
todos: [],
filter: 'all',
editingId: null
});

const addTodo = useCallback((text: string) => {
updateState(draft => {
draft.todos.push({
id: Date.now().toString(),
text,
completed: false
});
});
}, [updateState]);

const toggleTodo = useCallback((id: string) => {
updateState(draft => {
const todo = draft.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
});
}, [updateState]);

const updateTodo = useCallback((id: string, text: string) => {
updateState(draft => {
const todo = draft.todos.find(t => t.id === id);
if (todo) {
todo.text = text;
}
draft.editingId = null;
});
}, [updateState]);

const deleteTodo = useCallback((id: string) => {
updateState(draft => {
draft.todos = draft.todos.filter(t => t.id !== id);
});
}, [updateState]);

const setFilter = useCallback((filter: TodoState['filter']) => {
updateState(draft => {
draft.filter = filter;
});
}, [updateState]);

const setEditingId = useCallback((id: string | null) => {
updateState(draft => {
draft.editingId = id;
});
}, [updateState]);

const filteredTodos = useMemo(() => {
switch (state.filter) {
case 'active':
return state.todos.filter(t => !t.completed);
case 'completed':
return state.todos.filter(t => t.completed);
default:
return state.todos;
}
}, [state.todos, state.filter]);

return {
todos: filteredTodos,
filter: state.filter,
editingId: state.editingId,
addTodo,
toggleTodo,
updateTodo,
deleteTodo,
setFilter,
setEditingId
};
}

Custom Hooks for Complex Logic

useAsync Hook for Data Fetching

interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}

type AsyncAction<T> =
| { type: 'loading' }
| { type: 'success'; data: T }
| { type: 'error'; error: Error };

function asyncReducer<T>(state: AsyncState<T>, action: AsyncAction<T>): AsyncState<T> {
switch (action.type) {
case 'loading':
return { data: null, loading: true, error: null };
case 'success':
return { data: action.data, loading: false, error: null };
case 'error':
return { data: null, loading: false, error: action.error };
default:
return state;
}
}

function useAsync<T>(asyncFunction: () => Promise<T>, dependencies: any[] = []) {
const [state, dispatch] = useReducer(asyncReducer<T>, {
data: null,
loading: false,
error: null
});

const execute = useCallback(async () => {
dispatch({ type: 'loading' });
try {
const data = await asyncFunction();
dispatch({ type: 'success', data });
} catch (error) {
dispatch({ type: 'error', error: error as Error });
}
}, dependencies);

useEffect(() => {
execute();
}, [execute]);

return { ...state, execute };
}

// Advanced version with caching
function useAsyncWithCache<T>(
key: string,
asyncFunction: () => Promise<T>,
dependencies: any[] = []
) {
const cache = useRef<Map<string, T>>(new Map());
const [state, dispatch] = useReducer(asyncReducer<T>, {
data: cache.current.get(key) || null,
loading: false,
error: null
});

const execute = useCallback(async () => {
// Check cache first
if (cache.current.has(key)) {
dispatch({ type: 'success', data: cache.current.get(key)! });
return;
}

dispatch({ type: 'loading' });
try {
const data = await asyncFunction();
cache.current.set(key, data);
dispatch({ type: 'success', data });
} catch (error) {
dispatch({ type: 'error', error: error as Error });
}
}, [key, ...dependencies]);

useEffect(() => {
execute();
}, [execute]);

const invalidateCache = useCallback(() => {
cache.current.delete(key);
}, [key]);

return { ...state, execute, invalidateCache };
}

useWebSocket Hook

interface UseWebSocketOptions {
onMessage?: (event: MessageEvent) => void;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
shouldReconnect?: (closeEvent: CloseEvent) => boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}

function useWebSocket(
url: string | null,
options: UseWebSocketOptions = {}
) {
const {
onMessage,
onOpen,
onClose,
onError,
shouldReconnect = () => true,
reconnectInterval = 3000,
maxReconnectAttempts = 5
} = options;

const [socket, setSocket] = useState<WebSocket | null>(null);
const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null);
const [readyState, setReadyState] = useState<number>(WebSocket.CONNECTING);
const reconnectAttempts = useRef<number>(0);
const shouldConnect = useRef<boolean>(true);

const sendMessage = useCallback((message: string | ArrayBuffer | Blob) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(message);
} else {
console.warn('WebSocket is not connected');
}
}, [socket]);

const sendJsonMessage = useCallback((object: any) => {
sendMessage(JSON.stringify(object));
}, [sendMessage]);

const connect = useCallback(() => {
if (!url || !shouldConnect.current) return;

const ws = new WebSocket(url);

ws.onopen = (event) => {
setReadyState(WebSocket.OPEN);
reconnectAttempts.current = 0;
onOpen?.(event);
};

ws.onmessage = (event) => {
setLastMessage(event);
onMessage?.(event);
};

ws.onclose = (event) => {
setReadyState(WebSocket.CLOSED);
onClose?.(event);

if (
shouldReconnect(event) &&
shouldConnect.current &&
reconnectAttempts.current < maxReconnectAttempts
) {
reconnectAttempts.current++;
setTimeout(connect, reconnectInterval);
}
};

ws.onerror = (event) => {
setReadyState(WebSocket.CLOSED);
onError?.(event);
};

setSocket(ws);
}, [url, onOpen, onMessage, onClose, onError, shouldReconnect, reconnectInterval, maxReconnectAttempts]);

const disconnect = useCallback(() => {
shouldConnect.current = false;
if (socket) {
socket.close();
}
}, [socket]);

useEffect(() => {
shouldConnect.current = true;
connect();

return () => {
shouldConnect.current = false;
if (socket) {
socket.close();
}
};
}, [connect]);

return {
sendMessage,
sendJsonMessage,
lastMessage,
readyState,
socket,
connect,
disconnect
};
}

// Usage
function ChatComponent() {
const [messages, setMessages] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');

const { sendJsonMessage, lastMessage, readyState } = useWebSocket(
'ws://localhost:8080/chat',
{
onMessage: (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message.text]);
},
shouldReconnect: (closeEvent) => closeEvent.code !== 1000,
}
);

const handleSendMessage = () => {
if (inputValue.trim()) {
sendJsonMessage({ text: inputValue, timestamp: Date.now() });
setInputValue('');
}
};

const connectionStatus = {
[WebSocket.CONNECTING]: 'Connecting',
[WebSocket.OPEN]: 'Open',
[WebSocket.CLOSING]: 'Closing',
[WebSocket.CLOSED]: 'Closed',
}[readyState];

return (
<div>
<div>Status: {connectionStatus}</div>
<div>
{messages.map((message, index) => (
<div key={index}>{message}</div>
))}
</div>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<button onClick={handleSendMessage}>Send</button>
</div>
);
}

useLocalStorage Hook with Sync

function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
// Get initial value from localStorage or use provided initial value
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});

// Return a wrapped version of useState's setter function that persists the new value to localStorage
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;

// Save state
setStoredValue(valueToStore);

// Save to localStorage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};

// Listen for changes in other tabs/windows
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error(`Error parsing localStorage key "${key}":`, error);
}
}
};

window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);

return [storedValue, setValue];
}

// Advanced version with TTL
function useLocalStorageWithTTL<T>(
key: string,
initialValue: T,
ttl: number = 3600000 // 1 hour default
): [T, (value: T | ((val: T) => T)) => void, () => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
if (item) {
const parsedItem = JSON.parse(item);
if (parsedItem.expiry && Date.now() > parsedItem.expiry) {
window.localStorage.removeItem(key);
return initialValue;
}
return parsedItem.value;
}
return initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});

const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);

const itemWithTTL = {
value: valueToStore,
expiry: Date.now() + ttl
};

if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(itemWithTTL));
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};

const clearValue = useCallback(() => {
setStoredValue(initialValue);
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
}, [key, initialValue]);

return [storedValue, setValue, clearValue];
}

State Management with Context and Hooks

Advanced Context Pattern

// State type definitions
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
settings: UserSettings;
}

type AppAction =
| { type: 'SET_USER'; user: User | null }
| { type: 'SET_THEME'; theme: 'light' | 'dark' }
| { type: 'ADD_NOTIFICATION'; notification: Notification }
| { type: 'REMOVE_NOTIFICATION'; id: string }
| { type: 'UPDATE_SETTINGS'; settings: Partial<UserSettings> };

// Reducer
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.user };
case 'SET_THEME':
return { ...state, theme: action.theme };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.notification]
};
case 'REMOVE_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.id)
};
case 'UPDATE_SETTINGS':
return {
...state,
settings: { ...state.settings, ...action.settings }
};
default:
return state;
}
}

// Context creation
interface AppContextType {
state: AppState;
dispatch: Dispatch<AppAction>;
actions: {
setUser: (user: User | null) => void;
setTheme: (theme: 'light' | 'dark') => void;
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
updateSettings: (settings: Partial<UserSettings>) => void;
};
}

const AppContext = createContext<AppContextType | undefined>(undefined);

// Provider component
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: 'light',
notifications: [],
settings: {
emailNotifications: true,
pushNotifications: false,
language: 'en'
}
});

// Action creators
const actions = useMemo(() => ({
setUser: (user: User | null) => dispatch({ type: 'SET_USER', user }),
setTheme: (theme: 'light' | 'dark') => dispatch({ type: 'SET_THEME', theme }),
addNotification: (notification: Omit<Notification, 'id'>) =>
dispatch({
type: 'ADD_NOTIFICATION',
notification: { ...notification, id: Date.now().toString() }
}),
removeNotification: (id: string) => dispatch({ type: 'REMOVE_NOTIFICATION', id }),
updateSettings: (settings: Partial<UserSettings>) =>
dispatch({ type: 'UPDATE_SETTINGS', settings })
}), []);

// Persist theme to localStorage
useEffect(() => {
localStorage.setItem('theme', state.theme);
document.documentElement.className = state.theme;
}, [state.theme]);

const contextValue = useMemo(() => ({
state,
dispatch,
actions
}), [state, actions]);

return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
}

// Custom hook to use the context
export function useAppContext() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
}

// Specific hooks for different parts of the state
export function useUser() {
const { state, actions } = useAppContext();
return {
user: state.user,
setUser: actions.setUser
};
}

export function useTheme() {
const { state, actions } = useAppContext();
return {
theme: state.theme,
setTheme: actions.setTheme
};
}

export function useNotifications() {
const { state, actions } = useAppContext();
return {
notifications: state.notifications,
addNotification: actions.addNotification,
removeNotification: actions.removeNotification
};
}

Performance Optimization with React.memo and useMemo

// Memoized component with custom comparison
const ExpensiveListItem = React.memo<{
item: ListItem;
onUpdate: (id: string, data: any) => void;
isSelected: boolean;
}>(({ item, onUpdate, isSelected }) => {
const handleUpdate = useCallback(() => {
onUpdate(item.id, { lastViewed: Date.now() });
}, [item.id, onUpdate]);

const computedValue = useMemo(() => {
// Expensive computation
return item.data.reduce((acc, curr) => acc + curr.value, 0);
}, [item.data]);

return (
<div
className={`list-item ${isSelected ? 'selected' : ''}`}
onClick={handleUpdate}
>
<h3>{item.title}</h3>
<p>Computed Value: {computedValue}</p>
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function
return (
prevProps.item.id === nextProps.item.id &&
prevProps.isSelected === nextProps.isSelected &&
prevProps.item.title === nextProps.item.title &&
JSON.stringify(prevProps.item.data) === JSON.stringify(nextProps.item.data)
);
});

// Main component with optimized rendering
function OptimizedList() {
const [items, setItems] = useState<ListItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState('');

// Memoized filtered items
const filteredItems = useMemo(() => {
return items.filter(item =>
item.title.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);

// Memoized update function
const handleUpdateItem = useCallback((id: string, data: any) => {
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...data } : item
)
);
}, []);

return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter items..."
/>

<div className="list">
{filteredItems.map(item => (
<ExpensiveListItem
key={item.id}
item={item}
onUpdate={handleUpdateItem}
isSelected={selectedId === item.id}
/>
))}
</div>
</div>
);
}

Conclusion

Mastering React Hooks and advanced state management involves:

  1. Understanding Hook Patterns: useReducer for complex state, custom hooks for reusable logic
  2. Performance Optimization: React.memo, useMemo, useCallback for preventing unnecessary re-renders
  3. Context Patterns: Proper context design with action creators and specific hooks
  4. Custom Hooks: Encapsulating complex logic like data fetching, WebSocket connections, and localStorage
  5. State Architecture: Choosing the right tool (local state, context, external libraries) for your needs

These patterns will help you build maintainable, performant React applications that scale with your growing requirements.

Coming next: Building full-stack applications with React, Node.js, and TypeScript!