useDebugValue: Debug Custom Hooks in DevTools (2026)
When you build custom hooks, they become invisible to React DevTools by default. A parent component can see its own useState and useEffect, but the internal state and logic inside your custom hook remains a black box. That's where useDebugValue comes in—it lets you surface whatever debug information you want in DevTools, making it dramatically easier to understand what your custom hooks are doing.
If you've ever struggled to debug a custom hook while watching the component tree in DevTools, or found yourself sprinkling console.log statements everywhere just to understand hook behavior, this hook will save you countless debugging hours. It's one of the most underrated tools in React's DevTools ecosystem.
Table of Contents
- What is useDebugValue?
- The Problem It Solves
- Basic Syntax and Setup
- Formatting Strategies
- Real-World Debugging Scenarios
- Performance Considerations
- Common Pitfalls
- FAQ
What is useDebugValue?
useDebugValue is a React hook that lets you add custom labels and debug information to custom hooks that appear in React DevTools. It doesn't affect your production code or component behavior—it's purely a development tool.
Here's the basic signature:
useDebugValue(value, format?)
- value: Any value you want to display (state, computed values, status indicators)
- format (optional): A function that transforms the value into a human-readable string for display
The hook is lightweight and designed specifically for developers. When you open React DevTools and inspect a component using your custom hook, the debug value appears right next to the hook name, making it immediately clear what's happening inside.
Think of it as adding inline documentation that appears directly in DevTools. Instead of guessing what state a custom hook holds, you see it labeled and formatted exactly as you want.
The Problem It Solves
Imagine you've built a useAuth hook that manages user authentication state:
TypeScript Version (Without useDebugValue)
import { useState, useCallback } from 'react';
interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const data = await response.json();
setUser(data.user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
}, []);
return { user, isLoading, error, login };
}
When you open DevTools and inspect a component using this hook, you see generic hook names. There's no clear indication of what state the hook holds, whether it's loading, or if there's an error. You're left guessing.
With useDebugValue, you add explicit debug information:
TypeScript Version (With useDebugValue)
import { useState, useCallback, useDebugValue } from 'react';
interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Display auth status in DevTools
useDebugValue(
{ user: user?.name || 'guest', isLoading, error },
(state) => `${state.user} - ${state.isLoading ? 'Loading...' : 'Ready'}`
);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const data = await response.json();
setUser(data.user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
}, []);
return { user, isLoading, error, login };
}
Now when you inspect a component using useAuth in DevTools, you immediately see something like "alice - Ready" or "guest - Loading..." right in the hook list. No more guessing.
Basic Syntax and Setup
Step 1: Import useDebugValue
import { useDebugValue } from 'react';
Step 2: Add It to Your Custom Hook
Call it anywhere inside your hook, typically near the end:
export function useCustomHook() {
const [state, setState] = useState('value');
// Add debug info
useDebugValue(state);
return { state, setState };
}
Step 3: View in DevTools
- Open your app in Chrome or Firefox
- Open DevTools (F12)
- Go to the React DevTools tab (Components panel)
- Find your component in the tree
- Expand the Hooks section
- Your custom hook's debug value appears right there
Formatting Strategies
useDebugValue accepts an optional formatting function as the second argument. This is where you can make debug information more readable.
Strategy 1: Simple String Label
For basic cases, just pass a string:
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
useDebugValue(value ? 'ON' : 'OFF');
return { value, setValue };
}
DevTools shows: "ON" or "OFF"
Strategy 2: Format Function for Complex State
When you have complex state, use a formatter to create a readable representation:
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
export function useShoppingCart() {
const [items, setItems] = useState<CartItem[]>([]);
useDebugValue(items, (items) => {
const total = items.reduce((sum, item) => sum + item.quantity, 0);
const price = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return `${items.length} items, ${total} units, ¥${price.toFixed(2)}`;
});
return { items, setItems };
}
DevTools shows: "3 items, 8 units, ¥299.99"
Strategy 3: Status Indicator Pattern
Show the current state of an async operation:
type DataState = 'idle' | 'loading' | 'success' | 'error';
interface FetchState<T> {
data: T | null;
status: DataState;
error: Error | null;
}
export function useFetch<T>(url: string) {
const [state, setState] = useState<FetchState<T>>({
data: null,
status: 'idle',
error: null
});
useDebugValue(state, (s) => {
const icon = {
idle: '⏸️',
loading: '⏳',
success: '✅',
error: '❌'
}[s.status];
return `${icon} ${s.status.toUpperCase()}`;
});
// Fetch logic here...
return state;
}
DevTools shows: "⏳ LOADING" or "✅ SUCCESS" or "❌ ERROR"
Strategy 4: Conditional Debugging
Only show debug info when you need it:
export function useDataTable(data: DataRow[]) {
const [sortBy, setSortBy] = useState<keyof DataRow>('id');
const [filter, setFilter] = useState('');
// Only debug in development, only when filter is active
useDebugValue(
process.env.NODE_ENV === 'development' && filter ? filter : null,
(f) => f ? `Filtered by: ${f}` : ''
);
return { sortBy, setSortBy, filter, setFilter };
}
Real-World Debugging Scenarios
Scenario 1: Debugging Authentication State
In a Chinese e-commerce app (like Alibaba's internal tools), you need to track authentication throughout the component tree. useDebugValue makes it immediately obvious when a user logs in or out.
TypeScript Version
import { useState, useCallback, useDebugValue } from 'react';
type AuthStatus = 'unauthenticated' | 'authenticating' | 'authenticated' | 'error';
interface AuthUser {
uid: string;
username: string;
permissions: string[];
}
export function useUserAuth() {
const [user, setUser] = useState<AuthUser | null>(null);
const [status, setStatus] = useState<AuthStatus>('unauthenticated');
const [lastError, setLastError] = useState<string | null>(null);
// Clear debug display for sensitive data
useDebugValue(
{ username: user?.username, status },
(state) => {
if (state.username) {
return `${state.username} (${state.status})`;
}
return state.status;
}
);
const logout = useCallback(() => {
setUser(null);
setStatus('unauthenticated');
localStorage.removeItem('authToken');
}, []);
const login = useCallback(async (email: string, password: string) => {
setStatus('authenticating');
setLastError(null);
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('登录失败');
}
const data = await response.json();
setUser(data.user);
setStatus('authenticated');
localStorage.setItem('authToken', data.token);
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误';
setLastError(message);
setStatus('error');
}
}, []);
return { user, status, lastError, login, logout };
}
JavaScript Version
import { useState, useCallback, useDebugValue } from 'react';
export function useUserAuth() {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('unauthenticated');
const [lastError, setLastError] = useState(null);
useDebugValue(
{ username: user?.username, status },
(state) => {
if (state.username) {
return `${state.username} (${state.status})`;
}
return state.status;
}
);
const logout = useCallback(() => {
setUser(null);
setStatus('unauthenticated');
localStorage.removeItem('authToken');
}, []);
const login = useCallback(async (email, password) => {
setStatus('authenticating');
setLastError(null);
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('登录失败');
}
const data = await response.json();
setUser(data.user);
setStatus('authenticated');
localStorage.setItem('authToken', data.token);
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误';
setLastError(message);
setStatus('error');
}
}, []);
return { user, status, lastError, login, logout };
}
Debugging Benefit: When you inspect components using useUserAuth, you immediately see "alice (authenticated)" or "unauthenticated" without digging through multiple useState hooks. This is invaluable when debugging multi-user scenarios or permission issues.
Scenario 2: Debugging API Request State
Track multiple API requests at once in a complex data dashboard:
TypeScript Version
import { useReducer, useCallback, useDebugValue } from 'react';
interface ApiState<T> {
data: T | null;
isPending: boolean;
error: Error | null;
retryCount: number;
}
interface ApiAction<T> {
type: 'fetch_start' | 'fetch_success' | 'fetch_error' | 'retry';
payload?: T | Error;
}
export function useFetchData<T>(url: string, maxRetries = 3) {
const [state, dispatch] = useReducer(
(state: ApiState<T>, action: ApiAction<T>): ApiState<T> => {
switch (action.type) {
case 'fetch_start':
return { ...state, isPending: true, error: null };
case 'fetch_success':
return {
...state,
data: action.payload as T,
isPending: false,
retryCount: 0
};
case 'fetch_error':
return {
...state,
error: action.payload as Error,
isPending: false
};
case 'retry':
return { ...state, retryCount: state.retryCount + 1 };
default:
return state;
}
},
{ data: null, isPending: false, error: null, retryCount: 0 }
);
// Display API status with emoji indicators
useDebugValue(state, (s) => {
const statusIcon = s.isPending ? '⏳' : s.error ? '❌' : '✅';
const retryInfo = s.retryCount > 0 ? ` (重试 ${s.retryCount}次)` : '';
return `${statusIcon} ${url.split('/').pop()}${retryInfo}`;
});
const fetchData = useCallback(async () => {
dispatch({ type: 'fetch_start' });
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
dispatch({ type: 'fetch_success', payload: data });
} catch (error) {
dispatch({ type: 'fetch_error', payload: error as Error });
if (state.retryCount < maxRetries) {
dispatch({ type: 'retry' });
setTimeout(fetchData, 1000 * (state.retryCount + 1));
}
}
}, [url, state.retryCount, maxRetries]);
return { ...state, fetchData };
}
JavaScript Version
import { useReducer, useCallback, useDebugValue } from 'react';
export function useFetchData(url, maxRetries = 3) {
const [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
case 'fetch_start':
return { ...state, isPending: true, error: null };
case 'fetch_success':
return {
...state,
data: action.payload,
isPending: false,
retryCount: 0
};
case 'fetch_error':
return {
...state,
error: action.payload,
isPending: false
};
case 'retry':
return { ...state, retryCount: state.retryCount + 1 };
default:
return state;
}
},
{ data: null, isPending: false, error: null, retryCount: 0 }
);
useDebugValue(state, (s) => {
const statusIcon = s.isPending ? '⏳' : s.error ? '❌' : '✅';
const retryInfo = s.retryCount > 0 ? ` (重试 ${s.retryCount}次)` : '';
return `${statusIcon} ${url.split('/').pop()}${retryInfo}`;
});
const fetchData = useCallback(async () => {
dispatch({ type: 'fetch_start' });
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
dispatch({ type: 'fetch_success', payload: data });
} catch (error) {
dispatch({ type: 'fetch_error', payload: error });
if (state.retryCount < maxRetries) {
dispatch({ type: 'retry' });
setTimeout(fetchData, 1000 * (state.retryCount + 1));
}
}
}, [url, state.retryCount, maxRetries]);
return { ...state, fetchData };
}
Debugging Benefit: When you have 5+ API requests running simultaneously, DevTools shows you exactly which ones are pending, which failed, and how many retries occurred. This is critical when debugging network issues or race conditions.
Performance Considerations
Lazy Formatting for Expensive Computations
The format function runs whenever the hook renders, so avoid expensive operations:
// ❌ Bad - runs expensive computation every render
useDebugValue(items, (items) => {
return JSON.stringify(items); // Large object serialization
});
// ✅ Good - only format when needed
useDebugValue(items, (items) => {
return `${items.length} items`;
});
Only Debug in Development
You might want to skip debugging overhead entirely in production:
export function useMyHook() {
const [state, setState] = useState('value');
if (process.env.NODE_ENV === 'development') {
useDebugValue(state);
}
return { state, setState };
}
Note: The React team actually handles this automatically. useDebugValue is a no-op in production builds, so even if you leave it in your code, there's no performance penalty. But it's still good practice to be explicit.
Common Pitfalls
Pitfall 1: Exposing Sensitive Data
Don't put passwords, tokens, or personal information in debug values:
// ❌ Bad - exposes sensitive data
useDebugValue(authToken); // Anyone can see the token in DevTools
// ✅ Good - show status, not the actual secret
useDebugValue(token ? 'authenticated' : 'unauthenticated');
Pitfall 2: Creating New Objects in the Format Function
This can cause unnecessary re-renders of the entire component tree:
// ❌ Bad - creates new object every time
useDebugValue(data, (d) => ({
formatted: d.map(item => item.name)
}));
// ✅ Good - return primitive or use useMemo if needed
useDebugValue(data, (d) => d.map(item => item.name).join(', '));
Pitfall 3: Forgetting the Format Function When It's Needed
If your value isn't serializable or is too verbose, you need the format function:
// ❌ Not great - DevTools shows [Function] or long object
useDebugValue(new Date());
useDebugValue(largeDataStructure);
// ✅ Better - provide readable representation
useDebugValue(new Date(), (date) => date.toISOString());
useDebugValue(largeDataStructure, (obj) =>
`${Object.keys(obj).length} properties`
);
Pitfall 4: Relying on Console.log Instead
Don't skip useDebugValue in favor of console logging:
// ❌ Incomplete debugging approach
export function useMyHook() {
const [state, setState] = useState('value');
console.log('Hook state:', state); // Clutters console, easy to miss
return { state, setState };
}
// ✅ Better - visible in DevTools
export function useMyHook() {
const [state, setState] = useState('value');
useDebugValue(state);
return { state, setState };
}
FAQ
Q: Does useDebugValue affect production builds?
A: No. React automatically strips out useDebugValue calls during production builds. It's purely a development tool, so you can leave it in your code without any performance penalty.
Q: Can I use useDebugValue in multiple custom hooks?
A: Absolutely. In fact, you should use it liberally in all your custom hooks. Each hook's debug value appears separately in DevTools, making it easy to track multiple hooks at once.
Q: How does useDebugValue compare to console.log for debugging?
A: useDebugValue is superior because:
- It appears in DevTools directly, not buried in the console
- It's organized per-hook, making it easy to scan
- It doesn't clutter the console with dozens of log messages
- It's automatically disabled in production, no need to remove before shipping
For complex debugging, use both: useDebugValue for the overview, console.log for detailed investigation.
Q: Can I update the debug value based on user interaction?
A: Yes. The debug value updates whenever the hook's state changes. You don't do anything special—just change the value you're passing to useDebugValue and DevTools will reflect it immediately.
Q: What if I want to display different debug info for different environments?
A: Use the format function to conditionally return different strings:
useDebugValue(state, (s) => {
if (process.env.REACT_APP_DEBUG === 'verbose') {
return JSON.stringify(s);
}
return s.status;
});
Q: Does useDebugValue work with React 18 and above?
A: Yes, it's been available since React 16.8 and works in all versions including React 19. It's a stable, mature part of the hooks API.
Q: Can I use useDebugValue with TypeScript without issues?
A: Yes. TypeScript has good support for useDebugValue. The first parameter's type is inferred from your value, and the format function is typed as (value: T) => any.
useDebugValue(user, (u: User | null) => u?.name || 'guest');
Related Articles:
- Custom Hooks: Building Reusable Logic with React
- React DevTools: Profiling and Performance Debugging
- Common Custom Hook Patterns and Best Practices
Questions? Are you already using useDebugValue in your custom hooks? Or have you found creative ways to format debug values for complex state? Drop your experiences in the comments—I'd love to hear how you're using it to debug your applications!
Google AdSense Placeholder
CONTENT SLOT