What You’ll Learn in This Tutorial
✓ useState - State management basics
✓ useEffect - Side effect handling
✓ useContext - Global state sharing
✓ useReducer - Complex state management
✓ useMemo/useCallback - Performance optimization
✓ Custom Hooks - Logic reuse
Prerequisites
- Basic JavaScript knowledge
- Basic understanding of React (components, props)
- Node.js installed
Project Setup
# Create project
npx create-react-app hooks-tutorial
cd hooks-tutorial
# Start development server
npm start
Step 1: useState - State Management Basics
Basic Usage
// src/components/Counter.jsx
import { useState } from 'react';
export default function Counter() {
// [state value, update function] = useState(initial value)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
<button onClick={() => setCount(0)}>
Reset
</button>
</div>
);
}
Object State Management
// src/components/UserForm.jsx
import { useState } from 'react';
export default function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
age: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
// Use spread operator to preserve existing values while updating
setUser(prev => ({
...prev,
[name]: value
}));
};
return (
<form>
<input
name="name"
value={user.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
value={user.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="age"
type="number"
value={user.age}
onChange={handleChange}
placeholder="Age"
/>
<pre>{JSON.stringify(user, null, 2)}</pre>
</form>
);
}
Array State Management
// src/components/TodoList.jsx
import { useState } from 'react';
export default function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (!input.trim()) return;
setTodos([...todos, { id: Date.now(), text: input, done: false }]);
setInput('');
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Step 2: useEffect - Side Effect Handling
Basic useEffect
// src/components/Timer.jsx
import { useState, useEffect } from 'react';
export default function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
let interval = null;
if (isRunning) {
interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
}
// Cleanup function
return () => {
if (interval) clearInterval(interval);
};
}, [isRunning]); // Runs when isRunning changes
return (
<div>
<p>Elapsed time: {seconds} seconds</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? 'Stop' : 'Start'}
</button>
<button onClick={() => setSeconds(0)}>Reset</button>
</div>
);
}
Data Fetching
// src/components/UserList.jsx
import { useState, useEffect } from 'react';
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await fetch(
'https://jsonplaceholder.typicode.com/users'
);
if (!response.ok) throw new Error('Failed to fetch data');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []); // Empty dependency array = runs only on mount
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
);
}
Syncing with Local Storage
// src/components/PersistentCounter.jsx
import { useState, useEffect } from 'react';
export default function PersistentCounter() {
const [count, setCount] = useState(() => {
// Lazy initialization
const saved = localStorage.getItem('count');
return saved ? JSON.parse(saved) : 0;
});
useEffect(() => {
localStorage.setItem('count', JSON.stringify(count));
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Step 3: useContext - Global State Sharing
Creating and Using Context
// src/context/ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// src/components/ThemedApp.jsx
import { useTheme } from '../context/ThemeContext';
export default function ThemedApp() {
const { theme, toggleTheme } = useTheme();
const styles = {
container: {
backgroundColor: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff',
padding: '20px',
minHeight: '100vh'
}
};
return (
<div style={styles.container}>
<h1>Current theme: {theme}</h1>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// src/App.jsx
import { ThemeProvider } from './context/ThemeContext';
import ThemedApp from './components/ThemedApp';
export default function App() {
return (
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
);
}
Step 4: useReducer - Complex State Management
Implementing Reducer Pattern
// src/components/ShoppingCart.jsx
import { useReducer } from 'react';
const initialState = {
items: [],
total: 0
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find(
item => item.id === action.payload.id
);
if (existingItem) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
total: state.total + action.payload.price
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
total: state.total + action.payload.price
};
}
case 'REMOVE_ITEM': {
const item = state.items.find(i => i.id === action.payload);
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
total: state.total - (item.price * item.quantity)
};
}
case 'CLEAR_CART':
return initialState;
default:
return state;
}
}
const products = [
{ id: 1, name: 'Apple', price: 100 },
{ id: 2, name: 'Banana', price: 80 },
{ id: 3, name: 'Orange', price: 120 }
];
export default function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<div>
<h2>Products</h2>
{products.map(product => (
<div key={product.id}>
{product.name} - ${product.price}
<button onClick={() => dispatch({
type: 'ADD_ITEM',
payload: product
})}>
Add to Cart
</button>
</div>
))}
<h2>Cart</h2>
{state.items.map(item => (
<div key={item.id}>
{item.name} x {item.quantity} = ${item.price * item.quantity}
<button onClick={() => dispatch({
type: 'REMOVE_ITEM',
payload: item.id
})}>
Remove
</button>
</div>
))}
<p>Total: ${state.total}</p>
<button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
Clear Cart
</button>
</div>
);
}
Step 5: useMemo / useCallback - Performance Optimization
useMemo - Memoizing Computed Values
// src/components/ExpensiveCalculation.jsx
import { useState, useMemo } from 'react';
function slowFunction(num) {
console.log('Calculating...');
for (let i = 0; i < 1000000000; i++) {} // Heavy computation
return num * 2;
}
export default function ExpensiveCalculation() {
const [number, setNumber] = useState(0);
const [dark, setDark] = useState(false);
// Only recalculate when number changes
const doubledNumber = useMemo(() => {
return slowFunction(number);
}, [number]);
const theme = {
backgroundColor: dark ? '#333' : '#fff',
color: dark ? '#fff' : '#333'
};
return (
<div style={theme}>
<input
type="number"
value={number}
onChange={(e) => setNumber(parseInt(e.target.value) || 0)}
/>
<p>Result: {doubledNumber}</p>
<button onClick={() => setDark(!dark)}>
Toggle Theme
</button>
</div>
);
}
useCallback - Memoizing Functions
// src/components/CallbackExample.jsx
import { useState, useCallback, memo } from 'react';
// Memoized child component
const SearchButton = memo(({ onClick }) => {
console.log('SearchButton rendered');
return <button onClick={onClick}>Search</button>;
});
export default function CallbackExample() {
const [query, setQuery] = useState('');
const [count, setCount] = useState(0);
// Only create new function when query changes
const handleSearch = useCallback(() => {
console.log(`Searching: ${query}`);
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search term"
/>
<SearchButton onClick={handleSearch} />
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment (SearchButton won't re-render)
</button>
</div>
);
}
Step 6: Custom Hooks - Logic Reuse
useLocalStorage
// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
useFetch
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, {
signal: abortController.signal
});
if (!response.ok) throw new Error('Network error');
const json = await response.json();
setData(json);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
useDebounce
// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Custom Hook Usage Example
// src/components/SearchWithDebounce.jsx
import { useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import { useFetch } from '../hooks/useFetch';
export default function SearchWithDebounce() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 500);
const { data, loading, error } = useFetch(
debouncedSearch
? `https://api.github.com/search/users?q=${debouncedSearch}`
: null
);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search GitHub users..."
/>
{loading && <p>Searching...</p>}
{error && <p>Error: {error}</p>}
{data?.items?.map(user => (
<div key={user.id}>
<img src={user.avatar_url} alt="" width={32} />
{user.login}
</div>
))}
</div>
);
}
Best Practices
1. useState Tips
- Keep state minimal
- Derive values through computation, not useState
- Use spread operator when updating objects
2. useEffect Tips
- Set dependency arrays accurately
- Don't forget cleanup functions
- Watch out for infinite loops
3. Performance Optimization
- Avoid over-optimization
- Measure before optimizing
- Only use memo, useMemo, useCallback where needed
4. Custom Hooks
- Use for logic reuse
- Names must start with "use"
- Follow single responsibility principle
Summary
React Hooks enable function components to handle state management and side effects. After mastering useState and useEffect basics, tackle complex state management with useContext and useReducer, then optimize performance with useMemo and useCallback.
← Back to list