Mastering React Hooks for 2025
React Hooks have fundamentally transformed how we build React applications since their introduction in React 16.8. With React 18 and beyond, hooks have become even more powerful and essential for modern React development. In this post lets explores everything we need to know about hooks, from fundamental concepts to advanced patterns and the latest features introduced with latest React releases.
What Are React Hooks?
React Hooks are functions that let you “hook into” React features like state and lifecycle methods from functional components. They provide a more direct way to use React features without the complexity of class components, enabling better code reuse and composition. React hooks embrace functional programming approaches by powering up functional components.
Rules of Thumbs for React Hooks
Before diving in, remember the fundamental rules for react hooks to avoid undesired behaviours and performance degregation of application:
- Only call hooks at the top level - Never inside loops, conditions, or nested functions
- Only call hooks from React functions - React functional components or custom hooks
- Hook names must start with “use” - This helps React’s linter enforce the rules
Build-in react hooks
There are number of essential build-in react hooks we are using day to day react applications. These hook functionalites are primary building blocks of react UI design.
useState: Managing Component State
The useState
hook adds state to functional components with a clean, declarative API.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// Functional updates for complex state changes
const increment = () => setCount(prevCount => prevCount + 1);
// Object state updates (remember to spread!)
const [user, setUser] = useState({ name: '', email: '' });
const updateUserName = (newName) =>
setUser(prevUser => ({ ...prevUser, name: newName }));
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
</div>
);
}
Best Practices:
- Use functional updates when the new state depends on the previous state
- Separate related state variables for better organization
- Consider
useReducer
for complex state logic
useEffect: Side Effects and Lifecycle
The useEffect
hook handles side effects and replaces lifecycle methods from class components.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
// Prevent state updates if component unmounted
if (!cancelled) {
setUser(userData);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
setUser(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
// Cleanup function
return () => {
cancelled = true;
};
}, [userId]); // Dependency array
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
useReducer: Complex State Logic
For more complex state logic, useReducer
provides better organization and predictability.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import React, { useReducer } from 'react';
const initialState = {
items: [],
filter: 'all',
loading: false,
error: null
};
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, { id: Date.now(), text: action.text, completed: false }]
};
case 'TOGGLE_ITEM':
return {
...state,
items: state.items.map(item =>
item.id === action.id ? { ...item, completed: !item.completed } : item
)
};
case 'SET_FILTER':
return { ...state, filter: action.filter };
case 'SET_LOADING':
return { ...state, loading: action.loading };
case 'SET_ERROR':
return { ...state, error: action.error };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const addItem = (text) => {
dispatch({ type: 'ADD_ITEM', text });
};
const toggleItem = (id) => {
dispatch({ type: 'TOGGLE_ITEM', id });
};
const filteredItems = state.items.filter(item => {
if (state.filter === 'completed') return item.completed;
if (state.filter === 'active') return !item.completed;
return true;
});
return (
<div>
<h1>Todo App</h1>
{/* App implementation */}
</div>
);
}
useContext: Sharing Data Across Components
useContext
provides a cleaner way to consume context values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
const UserContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={ {theme, toggleTheme} }>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className={`btn btn-${theme}`}
>
Switch to {theme === 'light' ? 'dark' : 'light'} theme
</button>
);
}
Advanced Built-in Hooks
Advanced react hooks are used in specialised cases and will not be needed in many day-to-day user interface functionalities. But they are essential to achieve the requried behaviours when needed without using bloated code.
useRef: Accessing DOM Elements and Persisting Values
useRef
serves two main purposes: accessing DOM elements and storing mutable values that don’t trigger re-renders.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import React, { useRef, useEffect, useState } from 'react';
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const renderCount = useRef(0);
const [text, setText] = useState('');
useEffect(() => {
renderCount.current += 1;
});
const onButtonClick = () => {
// Access DOM element directly
inputEl.current.focus();
};
return (
<div>
<input
ref={inputEl}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onButtonClick}>Focus the input</button>
<p>Component rendered {renderCount.current} times</p>
</div>
);
}
useLayoutEffect: Synchronous Side Effects
useLayoutEffect
runs synchronously after all DOM mutations, useful for measuring DOM elements.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { useState, useLayoutEffect, useRef } from 'react';
function DynamicTooltip({ children, tooltip }) {
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const tooltipRef = useRef(null);
const triggerRef = useRef(null);
useLayoutEffect(() => {
if (tooltipRef.current && triggerRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setTooltipPosition({
x: triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2,
y: triggerRect.top - tooltipRect.height - 8
});
}
}, [tooltip]);
return (
<div className="tooltip-container">
<div ref={triggerRef}>
{children}
</div>
<div
ref={tooltipRef}
className="tooltip"
style=
>
{tooltip}
</div>
</div>
);
}
useImperativeHandle: Customizing Instance Values
Rarely used, but useful for exposing imperative API to parent components.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
scrollIntoView: () => {
inputRef.current.scrollIntoView();
},
getValue: () => {
return inputRef.current.value;
}
}));
return <input ref={inputRef} {...props} />;
});
function Parent() {
const fancyInputRef = useRef();
return (
<div>
<FancyInput ref={fancyInputRef} />
<button onClick={() => fancyInputRef.current.focus()}>
Focus input
</button>
</div>
);
}
React 18+ Concurrent Hooks
React 18 introduced several new hooks that work with concurrent features. These are essentails functionlities when we are creating complex but smooth and responsive user interfaces.
useId: Unique IDs for Accessibility
Generates unique IDs that are stable across server and client rendering.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, { useId } from 'react';
function FormField({ label, children }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<div id={id}>
{children}
</div>
</div>
);
}
function LoginForm() {
const passwordHintId = useId();
return (
<form>
<FormField label="Username">
<input name="username" />
</FormField>
<FormField label="Password">
<input
name="password"
type="password"
aria-describedby={passwordHintId}
/>
<div id={passwordHintId}>
Password must be at least 8 characters
</div>
</FormField>
</form>
);
}
useTransition: Non-Blocking State Updates
Mark state updates as non-urgent to keep the UI responsive.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { useState, useTransition, useDeferredValue } from 'react';
function SearchResults({ query }) {
// Expensive component that renders many results
const results = expensiveSearch(query);
return (
<div>
{results.map(result => (
<div key={result.id}>{result.title}</div>
))}
</div>
);
}
function SearchApp() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // Urgent update for input
// Non-urgent update for search results
startTransition(() => {
setSearchQuery(value);
});
};
return (
<div>
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Search..."
/>
{isPending && <div>Searching...</div>}
<SearchResults query={deferredQuery} />
</div>
);
}
useSyncExternalStore: Subscribing to External Stores
Safely subscribe to external data sources in concurrent React.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React, { useSyncExternalStore } from 'react';
// External store (could be Redux, Zustand, etc.)
class WindowSizeStore {
constructor() {
this.listeners = new Set();
this.width = window.innerWidth;
this.height = window.innerHeight;
window.addEventListener('resize', this.handleResize);
}
handleResize = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.listeners.forEach(listener => listener());
};
subscribe = (listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => ({
width: this.width,
height: this.height
});
}
const windowSizeStore = new WindowSizeStore();
function useWindowSize() {
return useSyncExternalStore(
windowSizeStore.subscribe,
windowSizeStore.getSnapshot,
() => ({ width: 1024, height: 768 }) // Server snapshot
);
}
function WindowInfo() {
const { width, height } = useWindowSize();
return (
<div>
Window size: {width} x {height}
</div>
);
}
Conclusion
React Hooks have fundamentally changed how we build React applications, providing a more intuitive and powerful way to manage state, side effects, and component logic. With React 18’s concurrent features and the growing ecosystem of hook-based libraries, mastering hooks is essential for modern React development.
Key takeaways for working with hooks effectively:
- Follow the Rules: Always call hooks at the top level and only from React functions
- Understand Dependencies: Properly manage dependency arrays to avoid bugs and performance issues
- Custom Hooks: Extract reusable logic into custom hooks for better code organization
- Performance: Use optimization hooks judiciously - measure first, optimize when needed
- Testing: Test your hooks thoroughly, focusing on behavior rather than implementation
- Stay Updated: Keep up with new hooks and features as React continues to evolve
The hooks ecosystem continues to grow and mature, with new patterns and best practices emerging regularly. By understanding these fundamentals and staying current with the latest developments, you’ll be well-equipped to build robust, performant React applications that take full advantage of what hooks have to offer.
Remember: hooks are just functions, but they unlock the power to make your React components more modular, reusable, and easier to reason about. The key is to use them thoughtfully and follow established patterns while staying open to new possibilities as the ecosystem evolves.