Explain the React component lifecycle in function components. How do useEffect dependencies work, and what are common pitfalls?
Answer:
React function components use hooks to manage lifecycle behavior, replacing the traditional class component lifecycle methods. The main lifecycle hook is useEffect, which handles side effects and cleanup.
Function Component Lifecycle Phases:
- Mount Phase:
function MyComponent() {
// 1. Component function runs (like constructor)
const [state, setState] = useState(initialValue);
// 2. useEffect with empty dependency array runs (like componentDidMount)
useEffect(() => {
console.log('Component mounted');
// Setup subscriptions, timers, etc.
// 3. Cleanup function (like componentWillUnmount)
return () => {
console.log('Component will unmount');
// Cleanup subscriptions, timers, etc.
};
}, []); // Empty dependency array = run once on mount
// 4. Render phase
return <div>Component content</div>;
}
- Update Phase:
function MyComponent({ userId }) {
const [user, setUser] = useState(null);
// Runs on every render (like componentDidUpdate)
useEffect(() => {
console.log('Component updated');
fetchUser(userId).then(setUser);
}, [userId]); // Runs when userId changes
// Runs on every render (no dependency array)
useEffect(() => {
console.log('Runs on every render');
}); // No dependency array = runs on every render
return <div>{user?.name}</div>;
}
- Unmount Phase:
function MyComponent() {
useEffect(() => {
const timer = setInterval(() => {
console.log('Timer tick');
}, 1000);
// Cleanup function runs on unmount
return () => {
clearInterval(timer);
console.log('Timer cleaned up');
};
}, []);
return <div>Component with timer</div>;
}
useEffect Dependencies Deep Dive:
The dependency array controls when the effect runs:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// 1. Empty array - runs once on mount
useEffect(() => {
console.log('Component mounted');
}, []);
// 2. Specific dependencies - runs when dependencies change
useEffect(() => {
if (userId) {
fetchUser(userId).then(setUser);
}
}, [userId]); // Runs when userId changes
// 3. No dependency array - runs on every render
useEffect(() => {
console.log('Runs on every render');
});
// 4. Multiple dependencies
useEffect(() => {
if (user && user.isActive) {
fetchUserPosts(user.id).then(setPosts);
}
}, [user]); // Runs when user object changes
// 5. Function dependencies (be careful!)
const fetchData = useCallback(() => {
return fetchUser(userId);
}, [userId]);
useEffect(() => {
fetchData().then(setUser);
}, [fetchData]); // Runs when fetchData function changes
}
Common useEffect Pitfalls:
- Missing Dependencies:
// BAD - Missing userId dependency
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // userId not in dependencies!
}, []); // Empty array - will only run once
return <div>{user?.name}</div>;
}
// GOOD - Include all dependencies
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // userId included in dependencies
return <div>{user?.name}</div>;
}
- Infinite Re-render Loops:
// BAD - Creates new object on every render
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
setData({ timestamp: Date.now() }); // New object every time!
}, [data]); // data changes, effect runs, data changes again...
return <div>{data?.timestamp}</div>;
}
// GOOD - Use useCallback or move object creation inside effect
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
setData({ timestamp: Date.now() });
}, []); // Run once on mount
return <div>{data?.timestamp}</div>;
}
- Stale Closures:
// BAD - Stale closure problem
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Always uses initial count value (0)
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array
return <div>{count}</div>;
}
// GOOD - Use functional updates
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1); // Uses latest count value
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
- Object/Array Dependencies:
// BAD - New object/array on every render
function MyComponent({ filters }) {
const [data, setData] = useState([]);
useEffect(() => {
fetchData(filters).then(setData);
}, [filters]); // filters is a new object every render
return <div>{data.length} items</div>;
}
// GOOD - Use useMemo for stable references
function MyComponent({ filters }) {
const [data, setData] = useState([]);
const stableFilters = useMemo(() => filters, [
filters.category,
filters.status,
filters.dateRange
]);
useEffect(() => {
fetchData(stableFilters).then(setData);
}, [stableFilters]);
return <div>{data.length} items</div>;
}
Follow-up: How would you implement componentDidMount, componentDidUpdate, and componentWillUnmount behavior using hooks?
Answer: Here's how to replicate class component lifecycle methods using hooks:
// Class component equivalent
class MyClassComponent extends React.Component {
componentDidMount() {
console.log('Component mounted');
this.setupSubscriptions();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.userId !== this.props.userId) {
this.fetchUserData();
}
}
componentWillUnmount() {
console.log('Component will unmount');
this.cleanupSubscriptions();
}
render() {
return <div>Class component</div>;
}
}
// Function component equivalent
function MyFunctionComponent({ userId }) {
const [userData, setUserData] = useState(null);
const prevUserIdRef = useRef();
// componentDidMount equivalent
useEffect(() => {
console.log('Component mounted');
setupSubscriptions();
// componentWillUnmount equivalent
return () => {
console.log('Component will unmount');
cleanupSubscriptions();
};
}, []); // Empty dependency array = mount/unmount only
// componentDidUpdate equivalent
useEffect(() => {
if (prevUserIdRef.current !== userId) {
fetchUserData(userId).then(setUserData);
prevUserIdRef.current = userId;
}
}, [userId]);
return <div>Function component</div>;
}
Advanced Lifecycle Patterns:
- Custom Hook for Lifecycle:
function useLifecycle({
onMount,
onUpdate,
onUnmount,
dependencies = []
}) {
const isFirstRender = useRef(true);
const prevDeps = useRef(dependencies);
// Mount effect
useEffect(() => {
if (onMount) {
onMount();
}
return () => {
if (onUnmount) {
onUnmount();
}
};
}, []);
// Update effect
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (onUpdate) {
onUpdate(prevDeps.current, dependencies);
}
prevDeps.current = dependencies;
}, dependencies);
}
// Usage
function MyComponent({ userId }) {
useLifecycle({
onMount: () => console.log('Mounted'),
onUpdate: (prevDeps, currentDeps) => {
if (prevDeps[0] !== currentDeps[0]) {
console.log('userId changed');
}
},
onUnmount: () => console.log('Unmounting'),
dependencies: [userId]
});
return <div>Component with custom lifecycle</div>;
}
- Conditional Effects:
function ConditionalComponent({ shouldFetch, userId }) {
// Only run effect when shouldFetch is true
useEffect(() => {
if (shouldFetch && userId) {
fetchUserData(userId);
}
}, [shouldFetch, userId]);
// Early return pattern
if (!shouldFetch) {
return <div>Fetching disabled</div>;
}
return <div>Fetching enabled</div>;
}
- Effect Cleanup Patterns:
function DataFetcher({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef();
useEffect(() => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
setLoading(true);
fetch(url, { signal: abortControllerRef.current.signal })
.then(response => response.json())
.then(setData)
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
})
.finally(() => setLoading(false));
// Cleanup function
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url]);
return <div>{loading ? 'Loading...' : JSON.stringify(data)}</div>;
}
Best Practices:
- Always include dependencies:
// Use ESLint plugin to catch missing dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// Only if you're absolutely sure about the dependencies
}, []);
- Use useCallback for stable function references:
function MyComponent({ onDataChange }) {
const [data, setData] = useState(null);
const handleDataChange = useCallback((newData) => {
setData(newData);
onDataChange?.(newData);
}, [onDataChange]);
useEffect(() => {
// handleDataChange is stable
setupDataListener(handleDataChange);
}, [handleDataChange]);
return <div>{data}</div>;
}
- Separate concerns with multiple effects:
function ComplexComponent({ userId, settings }) {
// Separate effects for different concerns
useEffect(() => {
// User data fetching
fetchUser(userId);
}, [userId]);
useEffect(() => {
// Settings synchronization
syncSettings(settings);
}, [settings]);
useEffect(() => {
// Analytics tracking
trackPageView();
}, []); // Run once on mount
return <div>Complex component</div>;
}
Understanding useEffect dependencies and lifecycle patterns is crucial for building reliable React applications. The key is to think about when effects should run and ensure proper cleanup to prevent memory leaks.
Compare different state management solutions (Context API, Redux, Zustand, Jotai). When would you choose each?
Answer: Choosing the right state management solution depends on your application's complexity, team preferences, and specific requirements. Here's a comprehensive comparison:
1. Context API (Built-in React)
Pros:
- Built into React, no additional dependencies
- Simple for small to medium applications
- Good for theme, authentication, or user preferences
- No learning curve for React developers
Cons:
- Can cause performance issues with frequent updates
- No built-in devtools or time-travel debugging
- Can lead to prop drilling if overused
- No middleware or side effect handling
When to Use:
- Small to medium applications
- Global state that doesn't change frequently
- Theme, language, or user authentication state
- When you want to avoid external dependencies
// Context API Example
interface AppState {
user: User | null;
theme: 'light' | 'dark';
language: string;
}
const AppContext = createContext<{
state: AppState;
dispatch: React.Dispatch<AppAction>;
} | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}
function useAppContext() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within AppProvider');
}
return context;
}
2. Redux Toolkit (RTK)
Pros:
- Predictable state updates with immutable patterns
- Excellent devtools with time-travel debugging
- Large ecosystem and community
- Great for complex applications
- Middleware support for side effects
- RTK Query for data fetching
Cons:
- Steep learning curve
- Lots of boilerplate (though RTK reduces this)
- Can be overkill for simple applications
- Bundle size impact
When to Use:
- Large, complex applications
- When you need time-travel debugging
- Applications with complex state logic
- When multiple developers need to work on state management
- When you need middleware for side effects
// Redux Toolkit Example
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface UserState {
users: User[];
loading: boolean;
error: string | null;
}
const initialState: UserState = {
users: [],
loading: false,
error: null,
};
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await api.getUsers();
return response.data;
}
);
const userSlice = createSlice({
name: 'users',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch users';
});
},
});
export const { clearError } = userSlice.actions;
export default userSlice.reducer;
3. Zustand
Pros:
- Minimal boilerplate
- Small bundle size (~2KB)
- No providers needed
- TypeScript-first
- Simple API
- Good performance
- Can be used outside React
Cons:
- Smaller ecosystem compared to Redux
- Less tooling and devtools
- No built-in middleware system
- Can become complex with large applications
When to Use:
- Medium-sized applications
- When you want Redux-like patterns with less boilerplate
- TypeScript projects
- When bundle size matters
- Applications that need state outside React
// Zustand Example
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface UserStore {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
addUser: (user: User) => void;
updateUser: (id: string, updates: Partial<User>) => void;
deleteUser: (id: string) => void;
clearError: () => void;
}
export const useUserStore = create<UserStore>()(
devtools(
persist(
(set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const users = await api.getUsers();
set({ users, loading: false });
} catch (error) {
set({
error: error.message,
loading: false
});
}
},
addUser: (user) => {
set((state) => ({
users: [...state.users, user]
}));
},
updateUser: (id, updates) => {
set((state) => ({
users: state.users.map(user =>
user.id === id ? { ...user, ...updates } : user
)
}));
},
deleteUser: (id) => {
set((state) => ({
users: state.users.filter(user => user.id !== id)
}));
},
clearError: () => set({ error: null }),
}),
{
name: 'user-store',
partialize: (state) => ({ users: state.users }),
}
)
)
);
4. Jotai
Pros:
- Atomic approach - each piece of state is independent
- Excellent TypeScript support
- No providers needed
- Great performance with fine-grained updates
- Composable and flexible
- Small bundle size
Cons:
- Different mental model (atomic vs global state)
- Smaller ecosystem
- Can be complex for simple state
- Less familiar to developers
When to Use:
- When you want fine-grained reactivity
- Complex state with many interdependencies
- When you need to avoid unnecessary re-renders
- Applications with complex derived state
- When you prefer atomic state management
// Jotai Example
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithQuery } from 'jotai/utils';
// Base atoms
const usersAtom = atom<User[]>([]);
const selectedUserIdAtom = atom<string | null>(null);
const searchQueryAtom = atom<string>('');
// Derived atoms
const filteredUsersAtom = atom((get) => {
const users = get(usersAtom);
const query = get(searchQueryAtom);
if (!query) return users;
return users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase())
);
});
const selectedUserAtom = atom((get) => {
const users = get(usersAtom);
const selectedId = get(selectedUserIdAtom);
return users.find(user => user.id === selectedId) || null;
});
// Async atom
const usersQueryAtom = atomWithQuery(() => ({
queryKey: ['users'],
queryFn: () => api.getUsers(),
}));
// Action atoms
const addUserAtom = atom(
null,
(get, set, newUser: User) => {
const currentUsers = get(usersAtom);
set(usersAtom, [...currentUsers, newUser]);
}
);
// Component usage
function UserList() {
const [searchQuery, setSearchQuery] = useAtom(searchQueryAtom);
const filteredUsers = useAtomValue(filteredUsersAtom);
const setSelectedUserId = useSetAtom(selectedUserIdAtom);
return (
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
/>
{filteredUsers.map(user => (
<div
key={user.id}
onClick={() => setSelectedUserId(user.id)}
>
{user.name}
</div>
))}
</div>
);
}
Comparison Table:
| Feature | Context API | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| Bundle Size | 0KB | ~50KB | ~2KB | ~3KB |
| Learning Curve | Low | High | Medium | Medium |
| Boilerplate | Low | Medium | Low | Low |
| DevTools | Basic | Excellent | Good | Good |
| TypeScript | Good | Excellent | Excellent | Excellent |
| Performance | Poor (frequent updates) | Good | Good | Excellent |
| Ecosystem | Small | Large | Medium | Small |
| Time Travel | No | Yes | No | No |
| Middleware | No | Yes | Limited | No |
Decision Matrix:
Choose Context API when:
- Building a small to medium app
- State doesn't change frequently
- You want to avoid external dependencies
- Simple global state (theme, auth, etc.)
Choose Redux Toolkit when:
- Building a large, complex application
- You need time-travel debugging
- Multiple developers working on state
- Complex state logic with side effects
- You need middleware support
Choose Zustand when:
- You want Redux-like patterns with less boilerplate
- Bundle size is important
- You need good TypeScript support
- Medium-sized applications
- You want to use state outside React
Choose Jotai when:
- You need fine-grained reactivity
- Complex derived state
- Performance is critical
- You prefer atomic state management
- Complex state interdependencies
Follow-up: How would you prevent unnecessary re-renders in a large application?
Answer: Preventing unnecessary re-renders is crucial for performance in large React applications. Here are comprehensive strategies:
1. React.memo for Component Memoization:
// Memoize expensive components
const ExpensiveComponent = React.memo(({ data, onUpdate }) => {
const processedData = useMemo(() => {
return data.map(item => ({
...item,
processed: expensiveCalculation(item)
}));
}, [data]);
return (
<div>
{processedData.map(item => (
<ItemComponent key={item.id} item={item} onUpdate={onUpdate} />
))}
</div>
);
});
// Custom comparison function
const UserCard = React.memo(({ user, onEdit }) => {
return (
<div>
<h3>{user.name}</h3>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
}, (prevProps, nextProps) => {
// Only re-render if user data actually changed
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email
);
});
2. useMemo for Expensive Calculations:
function DataVisualization({ data, filters }) {
// Memoize expensive calculations
const processedData = useMemo(() => {
console.log('Processing data...');
return data
.filter(item => filters.category === 'all' || item.category === filters.category)
.map(item => ({
...item,
score: calculateComplexScore(item),
trend: calculateTrend(item.history)
}))
.sort((a, b) => b.score - a.score);
}, [data, filters.category]);
// Memoize derived values
const statistics = useMemo(() => ({
total: processedData.length,
average: processedData.reduce((sum, item) => sum + item.score, 0) / processedData.length,
topPerformer: processedData[0]?.name || 'N/A'
}), [processedData]);
return (
<div>
<StatsDisplay stats={statistics} />
<Chart data={processedData} />
</div>
);
}
3. useCallback for Stable Function References:
function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
// Memoize event handlers
const handleUserSelect = useCallback((userId: string) => {
setSelectedUserId(userId);
}, []);
const handleUserUpdate = useCallback((userId: string, updates: Partial<User>) => {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === userId ? { ...user, ...updates } : user
)
);
}, []);
const handleUserDelete = useCallback((userId: string) => {
setUsers(prevUsers => prevUsers.filter(user => user.id !== userId));
if (selectedUserId === userId) {
setSelectedUserId(null);
}
}, [selectedUserId]);
// Memoize filtered users
const activeUsers = useMemo(() => {
return users.filter(user => user.isActive);
}, [users]);
return (
<div>
<UserList
users={activeUsers}
onUserSelect={handleUserSelect}
onUserUpdate={handleUserUpdate}
onUserDelete={handleUserDelete}
/>
{selectedUserId && (
<UserDetails
userId={selectedUserId}
onUpdate={handleUserUpdate}
/>
)}
</div>
);
}
4. State Structure Optimization:
// BAD - Nested state causes unnecessary re-renders
function BadExample() {
const [state, setState] = useState({
users: [],
ui: {
selectedUserId: null,
filters: { category: 'all', status: 'active' },
pagination: { page: 1, limit: 10 }
}
});
// Changing any UI property re-renders everything
const updateFilters = (filters) => {
setState(prev => ({
...prev,
ui: { ...prev.ui, filters }
}));
};
}
// GOOD - Separate concerns
function GoodExample() {
const [users, setUsers] = useState([]);
const [selectedUserId, setSelectedUserId] = useState(null);
const [filters, setFilters] = useState({ category: 'all', status: 'active' });
const [pagination, setPagination] = useState({ page: 1, limit: 10 });
// Only components using filters will re-render
const updateFilters = useCallback((newFilters) => {
setFilters(newFilters);
}, []);
}
5. Context Optimization:
// BAD - Single context with everything
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const [settings, setSettings] = useState({});
const value = {
user, setUser,
theme, setTheme,
notifications, setNotifications,
settings, setSettings
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// GOOD - Split contexts by domain
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// Or use atomic selectors
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
function useUserSelector(selector) {
const { user } = useUser();
return useMemo(() => selector(user), [user, selector]);
}
// Usage - only re-renders when user.name changes
function UserName() {
const name = useUserSelector(user => user?.name);
return <span>{name}</span>;
}
6. Virtual Scrolling for Large Lists:
import { FixedSizeList as List } from 'react-window';
function VirtualizedUserList({ users }) {
const Row = useCallback(({ index, style }) => (
<div style={style}>
<UserCard user={users[index]} />
</div>
), [users]);
return (
<List
height={600}
itemCount={users.length}
itemSize={80}
width="100%"
>
{Row}
</List>
);
}
7. Lazy Loading and Code Splitting:
// Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
function Dashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<TabNavigation activeTab={activeTab} onTabChange={setActiveTab} />
<Suspense fallback={<LoadingSpinner />}>
{activeTab === 'charts' && <HeavyChart />}
{activeTab === 'data' && <DataTable />}
</Suspense>
</div>
);
}
8. Custom Hooks for Performance:
// Custom hook for debounced search
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Custom hook for stable references
function useStableCallback<T extends (...args: any[]) => any>(callback: T): T {
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback((...args: any[]) => {
return callbackRef.current(...args);
}, []) as T;
}
// Usage
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const handleSearch = useStableCallback((searchQuery: string) => {
// This function reference is stable
performSearch(searchQuery);
});
useEffect(() => {
if (debouncedQuery) {
handleSearch(debouncedQuery);
}
}, [debouncedQuery, handleSearch]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
9. Performance Monitoring:
// Custom hook for performance monitoring
function useRenderCount(componentName: string) {
const renderCount = useRef(0);
renderCount.current++;
useEffect(() => {
console.log(`${componentName} rendered ${renderCount.current} times`);
});
}
// React DevTools Profiler
function ProfiledComponent() {
useRenderCount('ProfiledComponent');
return <div>Component content</div>;
}
// Wrap with Profiler
<Profiler id="UserList" onRender={onRenderCallback}>
<UserList users={users} />
</Profiler>
10. State Management Best Practices:
// Use selectors to prevent unnecessary re-renders
function UserList() {
// Only re-renders when users array changes
const users = useSelector(state => state.users.items);
// Only re-renders when loading state changes
const loading = useSelector(state => state.users.loading);
// Memoize expensive selectors
const activeUsers = useSelector(
useCallback(state =>
state.users.items.filter(user => user.isActive),
[]
)
);
return (
<div>
{loading ? <LoadingSpinner /> : <UserGrid users={activeUsers} />}
</div>
);
}
Key Takeaways:
- Measure First: Use React DevTools Profiler to identify actual performance bottlenecks
- Memoize Strategically: Don't over-memoize; focus on expensive operations and frequently re-rendering components
- Split State: Keep state as flat as possible and split by domain
- Use Stable References: Prevent unnecessary re-renders with useCallback and useMemo
- Lazy Load: Code split heavy components and load them on demand
- Virtual Scrolling: For large lists, use virtualization libraries
- Context Optimization: Split contexts and use selectors to minimize re-renders
- Monitor Performance: Use profiling tools to measure and optimize
The key is to profile your application first to identify the actual bottlenecks, then apply these techniques strategically rather than preemptively.
Explain React.memo, useMemo, and useCallback. What's the difference, and when should each be used?
Answer: React provides three main optimization hooks/methods to prevent unnecessary re-renders and expensive calculations. Understanding when and how to use each is crucial for building performant React applications.
React.memo - Component Memoization
React.memo is a higher-order component that memoizes the result of a component. It only re-renders when its props change.
// Basic usage
const ExpensiveComponent = React.memo(({ data, onUpdate }) => {
console.log('ExpensiveComponent rendered');
return (
<div>
<h3>Data: {data.title}</h3>
<button onClick={() => onUpdate(data.id)}>Update</button>
</div>
);
});
// Custom comparison function
const UserCard = React.memo(({ user, onEdit }) => {
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
}, (prevProps, nextProps) => {
// Only re-render if specific properties change
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email &&
prevProps.user.avatar === nextProps.user.avatar
);
});
// Usage
function UserList({ users, onUserEdit }) {
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onEdit={onUserEdit}
/>
))}
</div>
);
}
useMemo - Value Memoization
useMemo memoizes the result of a computation and only recalculates when dependencies change.
function DataVisualization({ data, filters, sortBy }) {
// Expensive calculation - only runs when data, filters, or sortBy changes
const processedData = useMemo(() => {
console.log('Processing data...');
return data
.filter(item => {
if (filters.category !== 'all' && item.category !== filters.category) {
return false;
}
if (filters.status !== 'all' && item.status !== filters.status) {
return false;
}
return true;
})
.map(item => ({
...item,
score: calculateComplexScore(item),
trend: calculateTrend(item.history),
normalizedValue: normalizeValue(item.value, data)
}))
.sort((a, b) => {
switch (sortBy) {
case 'score':
return b.score - a.score;
case 'name':
return a.name.localeCompare(b.name);
case 'date':
return new Date(b.date) - new Date(a.date);
default:
return 0;
}
});
}, [data, filters.category, filters.status, sortBy]);
// Derived calculations - only recalculates when processedData changes
const statistics = useMemo(() => ({
total: processedData.length,
average: processedData.reduce((sum, item) => sum + item.score, 0) / processedData.length,
topPerformer: processedData[0]?.name || 'N/A',
categories: [...new Set(processedData.map(item => item.category))],
scoreDistribution: calculateScoreDistribution(processedData)
}), [processedData]);
// Expensive object creation - memoized to prevent child re-renders
const chartConfig = useMemo(() => ({
data: processedData,
options: {
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Data Visualization' }
},
scales: {
y: { beginAtZero: true },
x: { type: 'category' }
}
}
}), [processedData]);
return (
<div>
<StatisticsDisplay stats={statistics} />
<Chart config={chartConfig} />
<DataTable data={processedData} />
</div>
);
}
useCallback - Function Memoization
useCallback memoizes a function and only recreates it when dependencies change.
function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Memoize event handlers to prevent child re-renders
const handleUserSelect = useCallback((userId: string) => {
setSelectedUserId(userId);
}, []);
const handleUserUpdate = useCallback((userId: string, updates: Partial<User>) => {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === userId ? { ...user, ...updates } : user
)
);
}, []);
const handleUserDelete = useCallback((userId: string) => {
setUsers(prevUsers => prevUsers.filter(user => user.id !== userId));
if (selectedUserId === userId) {
setSelectedUserId(null);
}
}, [selectedUserId]);
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
}, []);
// Memoize filtered users
const filteredUsers = useMemo(() => {
if (!searchQuery) return users;
return users.filter(user =>
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [users, searchQuery]);
// Memoize sorted users
const sortedUsers = useMemo(() => {
return [...filteredUsers].sort((a, b) => a.name.localeCompare(b.name));
}, [filteredUsers]);
return (
<div>
<SearchInput onSearch={handleSearch} />
<UserList
users={sortedUsers}
onUserSelect={handleUserSelect}
onUserUpdate={handleUserUpdate}
onUserDelete={handleUserDelete}
/>
{selectedUserId && (
<UserDetails
userId={selectedUserId}
onUpdate={handleUserUpdate}
/>
)}
</div>
);
}
Key Differences and When to Use Each:
| Hook/Method | Purpose | When to Use | Dependencies |
|---|---|---|---|
React.memo | Prevents component re-renders | When component receives same props frequently | Props comparison |
useMemo | Memoizes expensive calculations | Expensive computations, object/array creation | Value dependencies |
useCallback | Memoizes function references | Event handlers, functions passed as props | Function dependencies |
Detailed Comparison:
- React.memo vs useMemo:
// React.memo - prevents component re-renders
const ExpensiveComponent = React.memo(({ data }) => {
// This component only re-renders when 'data' prop changes
return <div>{data.title}</div>;
});
// useMemo - prevents expensive calculations
function ParentComponent({ data }) {
const expensiveValue = useMemo(() => {
// This calculation only runs when 'data' changes
return data.reduce((sum, item) => sum + item.value, 0);
}, [data]);
return <div>{expensiveValue}</div>;
}
- useCallback vs useMemo:
function Example() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// useMemo - memoizes a value
const expensiveValue = useMemo(() => {
return count * 1000; // Expensive calculation
}, [count]);
// useCallback - memoizes a function
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return (
<div>
<p>Value: {expensiveValue}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
Advanced Patterns:
- Combining All Three:
// Memoized component
const UserCard = React.memo(({ user, onEdit, onDelete }) => {
// Memoized expensive calculation
const userStats = useMemo(() => {
return {
activityScore: calculateActivityScore(user),
riskLevel: assessRiskLevel(user),
recommendations: generateRecommendations(user)
};
}, [user.id, user.lastActivity, user.transactions]);
// Memoized event handlers
const handleEdit = useCallback(() => {
onEdit(user.id);
}, [user.id, onEdit]);
const handleDelete = useCallback(() => {
onDelete(user.id);
}, [user.id, onDelete]);
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>Activity Score: {userStats.activityScore}</p>
<p>Risk Level: {userStats.riskLevel}</p>
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
);
});
// Parent component with stable references
function UserList({ users, onUserEdit, onUserDelete }) {
const handleUserEdit = useCallback((userId: string) => {
onUserEdit(userId);
}, [onUserEdit]);
const handleUserDelete = useCallback((userId: string) => {
onUserDelete(userId);
}, [onUserDelete]);
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onEdit={handleUserEdit}
onDelete={handleUserDelete}
/>
))}
</div>
);
}
- Custom Comparison Functions:
// Deep comparison for complex objects
const DeepMemoComponent = React.memo(({ config, data }) => {
return <ComplexVisualization config={config} data={data} />;
}, (prevProps, nextProps) => {
// Custom deep comparison
return (
JSON.stringify(prevProps.config) === JSON.stringify(nextProps.config) &&
prevProps.data.length === nextProps.data.length &&
prevProps.data.every((item, index) =>
JSON.stringify(item) === JSON.stringify(nextProps.data[index])
)
);
});
// Shallow comparison for specific properties
const SelectiveMemoComponent = React.memo(({ user, settings, onUpdate }) => {
return <UserProfile user={user} settings={settings} onUpdate={onUpdate} />;
}, (prevProps, nextProps) => {
// Only compare specific properties
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.settings.theme === nextProps.settings.theme
// Ignore other properties
);
});
- Conditional Memoization:
function ConditionalComponent({ data, shouldOptimize }) {
// Only memoize when optimization is enabled
const processedData = shouldOptimize
? useMemo(() => expensiveProcessing(data), [data])
: expensiveProcessing(data);
return <div>{processedData}</div>;
}
// Or with custom hook
function useConditionalMemo<T>(factory: () => T, deps: any[], condition: boolean): T {
return condition ? useMemo(factory, deps) : factory();
}
Common Pitfalls and Best Practices:
- Don't Over-Memoize:
// BAD - Unnecessary memoization
function SimpleComponent({ name }) {
const memoizedName = useMemo(() => name, [name]); // Unnecessary!
return <div>{memoizedName}</div>;
}
// GOOD - Only memoize when needed
function SimpleComponent({ name }) {
return <div>{name}</div>; // Simple values don't need memoization
}
- Include All Dependencies:
// BAD - Missing dependencies
function BadExample({ userId, filters }) {
const data = useMemo(() => {
return fetchData(userId, filters); // filters not in deps!
}, [userId]);
return <div>{data}</div>;
}
// GOOD - Include all dependencies
function GoodExample({ userId, filters }) {
const data = useMemo(() => {
return fetchData(userId, filters);
}, [userId, filters]);
return <div>{data}</div>;
}
- Use Stable References:
// BAD - New object on every render
function BadParent() {
const config = { theme: 'dark', size: 'large' }; // New object every time!
return <ChildComponent config={config} />;
}
// GOOD - Memoized object
function GoodParent() {
const config = useMemo(() => ({
theme: 'dark',
size: 'large'
}), []);
return <ChildComponent config={config} />;
}
Coding Challenge: Identify and fix performance issues in a provided code sample with excessive re-renders.
Problem Code:
function UserDashboard({ userId }) {
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [filters, setFilters] = useState({ status: 'all', role: 'all' });
// Problem 1: Expensive calculation runs on every render
const processedUsers = users.map(user => ({
...user,
score: calculateUserScore(user),
risk: assessRisk(user),
recommendations: generateRecommendations(user)
}));
// Problem 2: New object created on every render
const chartData = {
users: processedUsers,
config: { type: 'bar', colors: ['blue', 'green'] }
};
// Problem 3: New function created on every render
const handleUserSelect = (user) => {
setSelectedUser(user);
};
// Problem 4: New function created on every render
const handleFilterChange = (newFilters) => {
setFilters(newFilters);
};
// Problem 5: Expensive filtering runs on every render
const filteredUsers = processedUsers.filter(user => {
if (filters.status !== 'all' && user.status !== filters.status) return false;
if (filters.role !== 'all' && user.role !== filters.role) return false;
return true;
});
return (
<div>
<FilterPanel filters={filters} onFilterChange={handleFilterChange} />
<UserChart data={chartData} />
<UserList
users={filteredUsers}
onUserSelect={handleUserSelect}
/>
{selectedUser && <UserDetails user={selectedUser} />}
</div>
);
}
Optimized Solution:
function UserDashboard({ userId }) {
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [filters, setFilters] = useState({ status: 'all', role: 'all' });
// Fix 1: Memoize expensive calculations
const processedUsers = useMemo(() => {
return users.map(user => ({
...user,
score: calculateUserScore(user),
risk: assessRisk(user),
recommendations: generateRecommendations(user)
}));
}, [users]);
// Fix 2: Memoize filtered users
const filteredUsers = useMemo(() => {
return processedUsers.filter(user => {
if (filters.status !== 'all' && user.status !== filters.status) return false;
if (filters.role !== 'all' && user.role !== filters.role) return false;
return true;
});
}, [processedUsers, filters.status, filters.role]);
// Fix 3: Memoize chart data object
const chartData = useMemo(() => ({
users: filteredUsers,
config: { type: 'bar', colors: ['blue', 'green'] }
}), [filteredUsers]);
// Fix 4: Memoize event handlers
const handleUserSelect = useCallback((user) => {
setSelectedUser(user);
}, []);
const handleFilterChange = useCallback((newFilters) => {
setFilters(newFilters);
}, []);
return (
<div>
<FilterPanel filters={filters} onFilterChange={handleFilterChange} />
<UserChart data={chartData} />
<UserList
users={filteredUsers}
onUserSelect={handleUserSelect}
/>
{selectedUser && <UserDetails user={selectedUser} />}
</div>
);
}
// Fix 5: Memoize child components
const UserList = React.memo(({ users, onUserSelect }) => {
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onSelect={onUserSelect}
/>
))}
</div>
);
});
const UserCard = React.memo(({ user, onSelect }) => {
const handleClick = useCallback(() => {
onSelect(user);
}, [user, onSelect]);
return (
<div onClick={handleClick}>
<h3>{user.name}</h3>
<p>Score: {user.score}</p>
<p>Risk: {user.risk}</p>
</div>
);
});
Performance Monitoring:
// Custom hook to measure render performance
function useRenderPerformance(componentName: string) {
const renderCount = useRef(0);
const startTime = useRef(performance.now());
renderCount.current++;
useEffect(() => {
const endTime = performance.now();
const renderTime = endTime - startTime.current;
console.log(`${componentName} rendered ${renderCount.current} times in ${renderTime.toFixed(2)}ms`);
startTime.current = performance.now();
});
}
// Usage
function OptimizedComponent() {
useRenderPerformance('OptimizedComponent');
return <div>Component content</div>;
}
Key Takeaways:
- React.memo: Use for components that receive the same props frequently
- useMemo: Use for expensive calculations and object/array creation
- useCallback: Use for functions passed as props to prevent child re-renders
- Measure First: Use React DevTools Profiler to identify actual bottlenecks
- Don't Over-Optimize: Only optimize when you have performance issues
- Include Dependencies: Always include all dependencies in dependency arrays
- Stable References: Ensure objects and functions have stable references
The key is to understand that these optimizations come with a cost (memory usage, complexity) and should only be used when they provide actual performance benefits.
What are the rules of hooks? Design a custom hook for handling form state with validation.
Answer: Custom hooks are functions that start with "use" and can call other hooks. They allow you to extract component logic into reusable functions, making your code more modular and testable.
Rules of Hooks:
-
Only Call Hooks at the Top Level:
- Never call hooks inside loops, conditions, or nested functions
- Always call hooks in the same order on every render
-
Only Call Hooks from React Functions:
- Call hooks from React function components
- Call hooks from other custom hooks
// ✅ GOOD - Hooks called at top level
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <div>{count}</div>;
}
// ❌ BAD - Hooks called conditionally
function BadComponent({ shouldUseEffect }) {
const [count, setCount] = useState(0);
if (shouldUseEffect) {
useEffect(() => { // This violates the rules!
document.title = `Count: ${count}`;
}, [count]);
}
return <div>{count}</div>;
}
// ❌ BAD - Hooks called in loops
function BadComponent({ items }) {
const [count, setCount] = useState(0);
items.forEach(item => {
useEffect(() => { // This violates the rules!
console.log(item);
}, [item]);
});
return <div>{count}</div>;
}
Custom Hook for Form State with Validation:
Here's a comprehensive form hook that handles state, validation, and submission:
// Types for form validation
interface ValidationRule<T> {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: T) => string | undefined;
message?: string;
}
interface FormField<T> {
value: T;
error: string | undefined;
touched: boolean;
rules: ValidationRule<T>[];
}
interface FormState<T extends Record<string, any>> {
fields: { [K in keyof T]: FormField<T[K]> };
isValid: boolean;
isSubmitting: boolean;
isDirty: boolean;
}
interface UseFormOptions<T extends Record<string, any>> {
initialValues: T;
validationRules?: Partial<{ [K in keyof T]: ValidationRule<T[K]>[] }>;
onSubmit: (values: T) => Promise<void> | void;
validateOnChange?: boolean;
validateOnBlur?: boolean;
}
// Custom form hook
function useForm<T extends Record<string, any>>({
initialValues,
validationRules = {},
onSubmit,
validateOnChange = true,
validateOnBlur = true
}: UseFormOptions<T>) {
// Initialize form state
const [formState, setFormState] = useState<FormState<T>>(() => {
const fields = {} as { [K in keyof T]: FormField<T[K]> };
for (const key in initialValues) {
fields[key] = {
value: initialValues[key],
error: undefined,
touched: false,
rules: validationRules[key] || []
};
}
return {
fields,
isValid: true,
isSubmitting: false,
isDirty: false
};
});
// Validation function
const validateField = useCallback(<K extends keyof T>(
fieldName: K,
value: T[K],
rules: ValidationRule<T[K]>[]
): string | undefined => {
for (const rule of rules) {
// Required validation
if (rule.required && (value === undefined || value === null || value === '')) {
return rule.message || `${String(fieldName)} is required`;
}
// Skip other validations if value is empty and not required
if (!rule.required && (value === undefined || value === null || value === '')) {
continue;
}
// String length validations
if (typeof value === 'string') {
if (rule.minLength && value.length < rule.minLength) {
return rule.message || `${String(fieldName)} must be at least ${rule.minLength} characters`;
}
if (rule.maxLength && value.length > rule.maxLength) {
return rule.message || `${String(fieldName)} must be no more than ${rule.maxLength} characters`;
}
if (rule.pattern && !rule.pattern.test(value)) {
return rule.message || `${String(fieldName)} format is invalid`;
}
}
// Custom validation
if (rule.custom) {
const customError = rule.custom(value);
if (customError) {
return customError;
}
}
}
return undefined;
}, []);
// Update field value
const setFieldValue = useCallback(<K extends keyof T>(
fieldName: K,
value: T[K]
) => {
setFormState(prevState => {
const field = prevState.fields[fieldName];
const error = validateOnChange
? validateField(fieldName, value, field.rules)
: field.error;
const newFields = {
...prevState.fields,
[fieldName]: {
...field,
value,
error,
touched: true
}
};
// Check if form is valid
const isValid = Object.values(newFields).every(field => !field.error);
// Check if form is dirty
const isDirty = Object.keys(newFields).some(key =>
newFields[key as keyof T].value !== initialValues[key as keyof T]
);
return {
...prevState,
fields: newFields,
isValid,
isDirty
};
});
}, [validateField, validateOnChange, initialValues]);
// Set field error
const setFieldError = useCallback(<K extends keyof T>(
fieldName: K,
error: string | undefined
) => {
setFormState(prevState => ({
...prevState,
fields: {
...prevState.fields,
[fieldName]: {
...prevState.fields[fieldName],
error
}
}
}));
}, []);
// Touch field
const touchField = useCallback(<K extends keyof T>(fieldName: K) => {
setFormState(prevState => {
const field = prevState.fields[fieldName];
const error = validateOnBlur
? validateField(fieldName, field.value, field.rules)
: field.error;
return {
...prevState,
fields: {
...prevState.fields,
[fieldName]: {
...field,
touched: true,
error
}
}
};
});
}, [validateField, validateOnBlur]);
// Validate all fields
const validateForm = useCallback(() => {
setFormState(prevState => {
const newFields = { ...prevState.fields };
let isValid = true;
for (const key in newFields) {
const field = newFields[key];
const error = validateField(key, field.value, field.rules);
newFields[key] = {
...field,
error,
touched: true
};
if (error) {
isValid = false;
}
}
return {
...prevState,
fields: newFields,
isValid
};
});
return formState.isValid;
}, [validateField, formState.isValid]);
// Reset form
const resetForm = useCallback(() => {
setFormState(prevState => {
const fields = {} as { [K in keyof T]: FormField<T[K]> };
for (const key in initialValues) {
fields[key] = {
value: initialValues[key],
error: undefined,
touched: false,
rules: validationRules[key] || []
};
}
return {
fields,
isValid: true,
isSubmitting: false,
isDirty: false
};
});
}, [initialValues, validationRules]);
// Submit form
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate all fields
const isValid = validateForm();
if (!isValid) {
return;
}
setFormState(prevState => ({ ...prevState, isSubmitting: true }));
try {
// Extract values from form state
const values = {} as T;
for (const key in formState.fields) {
values[key] = formState.fields[key].value;
}
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setFormState(prevState => ({ ...prevState, isSubmitting: false }));
}
}, [validateForm, formState.fields, onSubmit]);
// Get field props for input elements
const getFieldProps = useCallback(<K extends keyof T>(fieldName: K) => {
const field = formState.fields[fieldName];
return {
value: field.value,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFieldValue(fieldName, e.target.value as T[K]);
},
onBlur: () => touchField(fieldName),
error: field.error,
touched: field.touched,
hasError: field.touched && !!field.error
};
}, [formState.fields, setFieldValue, touchField]);
// Get form values
const getValues = useCallback(() => {
const values = {} as T;
for (const key in formState.fields) {
values[key] = formState.fields[key].value;
}
return values;
}, [formState.fields]);
// Set form values
const setValues = useCallback((values: Partial<T>) => {
setFormState(prevState => {
const newFields = { ...prevState.fields };
for (const key in values) {
if (key in newFields) {
const field = newFields[key];
const error = validateOnChange
? validateField(key, values[key]!, field.rules)
: field.error;
newFields[key] = {
...field,
value: values[key]!,
error,
touched: true
};
}
}
// Recalculate form validity
const isValid = Object.values(newFields).every(field => !field.error);
const isDirty = Object.keys(newFields).some(key =>
newFields[key as keyof T].value !== initialValues[key as keyof T]
);
return {
...prevState,
fields: newFields,
isValid,
isDirty
};
});
}, [validateField, validateOnChange, initialValues]);
return {
// Form state
isValid: formState.isValid,
isSubmitting: formState.isSubmitting,
isDirty: formState.isDirty,
// Field methods
getFieldProps,
setFieldValue,
setFieldError,
touchField,
// Form methods
validateForm,
resetForm,
handleSubmit,
getValues,
setValues,
// Raw form state (for advanced usage)
formState
};
}
// Usage example
interface UserFormData {
name: string;
email: string;
age: number;
password: string;
confirmPassword: string;
terms: boolean;
}
function UserRegistrationForm() {
const form = useForm<UserFormData>({
initialValues: {
name: '',
email: '',
age: 0,
password: '',
confirmPassword: '',
terms: false
},
validationRules: {
name: [
{ required: true, message: 'Name is required' },
{ minLength: 2, message: 'Name must be at least 2 characters' },
{ maxLength: 50, message: 'Name must be no more than 50 characters' }
],
email: [
{ required: true, message: 'Email is required' },
{
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address'
}
],
age: [
{ required: true, message: 'Age is required' },
{
custom: (value) => {
if (value < 18) return 'You must be at least 18 years old';
if (value > 120) return 'Please enter a valid age';
return undefined;
}
}
],
password: [
{ required: true, message: 'Password is required' },
{ minLength: 8, message: 'Password must be at least 8 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must contain at least one lowercase letter, one uppercase letter, and one number'
}
],
confirmPassword: [
{ required: true, message: 'Please confirm your password' },
{
custom: (value) => {
if (value !== form.getValues().password) {
return 'Passwords do not match';
}
return undefined;
}
}
],
terms: [
{
custom: (value) => {
if (!value) return 'You must accept the terms and conditions';
return undefined;
}
}
]
},
onSubmit: async (values) => {
console.log('Submitting form:', values);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Registration successful!');
form.resetForm();
}
});
const nameProps = form.getFieldProps('name');
const emailProps = form.getFieldProps('email');
const ageProps = form.getFieldProps('age');
const passwordProps = form.getFieldProps('password');
const confirmPasswordProps = form.getFieldProps('confirmPassword');
const termsProps = form.getFieldProps('terms');
return (
<form onSubmit={form.handleSubmit} className="user-form">
<h2>User Registration</h2>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
{...nameProps}
className={nameProps.hasError ? 'error' : ''}
/>
{nameProps.hasError && (
<span className="error-message">{nameProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...emailProps}
className={emailProps.hasError ? 'error' : ''}
/>
{emailProps.hasError && (
<span className="error-message">{emailProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
{...ageProps}
className={ageProps.hasError ? 'error' : ''}
/>
{ageProps.hasError && (
<span className="error-message">{ageProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...passwordProps}
className={passwordProps.hasError ? 'error' : ''}
/>
{passwordProps.hasError && (
<span className="error-message">{passwordProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...confirmPasswordProps}
className={confirmPasswordProps.hasError ? 'error' : ''}
/>
{confirmPasswordProps.hasError && (
<span className="error-message">{confirmPasswordProps.error}</span>
)}
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={termsProps.value}
onChange={(e) => form.setFieldValue('terms', e.target.checked)}
onBlur={() => form.touchField('terms')}
/>
I accept the terms and conditions
</label>
{termsProps.hasError && (
<span className="error-message">{termsProps.error}</span>
)}
</div>
<div className="form-actions">
<button
type="button"
onClick={form.resetForm}
disabled={form.isSubmitting}
>
Reset
</button>
<button
type="submit"
disabled={!form.isValid || form.isSubmitting}
>
{form.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</div>
<div className="form-status">
<p>Form is {form.isValid ? 'valid' : 'invalid'}</p>
<p>Form is {form.isDirty ? 'dirty' : 'clean'}</p>
</div>
</form>
);
}
Additional Custom Hooks Examples:
- useLocalStorage Hook:
function useLocalStorage<T>(key: string, initialValue: T) {
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;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue] as const;
}
- useDebounce Hook:
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
- useAsync Hook:
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAsync<T>(asyncFunction: () => Promise<T>, dependencies: any[] = []) {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: false,
error: null
});
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFunction();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error')
});
}
}, dependencies);
useEffect(() => {
execute();
}, [execute]);
return { ...state, refetch: execute };
}
Best Practices for Custom Hooks:
- Always start with "use":
// ✅ Good
function useCounter() { /* ... */ }
function useLocalStorage() { /* ... */ }
// ❌ Bad
function counter() { /* ... */ }
function localStorage() { /* ... */ }
- Return consistent interfaces:
// ✅ Good - consistent return pattern
function useToggle(initialValue: boolean) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle] as const;
}
- Handle cleanup properly:
function useEventListener(
eventName: string,
handler: (event: Event) => void,
element: Element | Window = window
) {
useEffect(() => {
element.addEventListener(eventName, handler);
return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, element]);
}
- Use TypeScript for better type safety:
interface UseApiOptions<T> {
url: string;
initialData?: T;
dependencies?: any[];
}
function useApi<T>({ url, initialData, dependencies = [] }: UseApiOptions<T>) {
// Implementation with proper typing
}
Custom hooks are powerful tools for code reuse and separation of concerns. They allow you to extract complex logic from components and make it reusable across your application while maintaining the benefits of React's hooks system.
Explain React 18's concurrent features (useTransition, useDeferredValue, Suspense). How do they improve user experience?
Answer: React 18 introduced concurrent features that allow React to interrupt, pause, and resume work, enabling better user experience through non-blocking updates and improved responsiveness. These features work together to make applications feel more responsive and fluid.
Core Concurrent Features:
1. useTransition - Marking Updates as Non-Urgent
useTransition allows you to mark state updates as transitions, which are non-urgent and can be interrupted by more urgent updates.
import { useTransition, useState, useMemo } from 'react';
function SearchResults({ query }: { query: string }) {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState<string[]>([]);
// Expensive computation that can be deferred
const expensiveResults = useMemo(() => {
if (!query) return [];
// Simulate expensive search operation
return Array.from({ length: 10000 }, (_, i) =>
`Result ${i} for "${query}"`
).filter(result =>
result.toLowerCase().includes(query.toLowerCase())
);
}, [query]);
const handleSearch = (newQuery: string) => {
// Mark this update as a transition (non-urgent)
startTransition(() => {
setResults(expensiveResults);
});
};
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
{isPending && <div>Searching...</div>}
<div>
{results.map((result, index) => (
<div key={index}>{result}</div>
))}
</div>
</div>
);
}
// More practical example with data fetching
function UserList() {
const [isPending, startTransition] = useTransition();
const [users, setUsers] = useState<User[]>([]);
const [filter, setFilter] = useState('');
const handleFilterChange = (newFilter: string) => {
// Urgent update - happens immediately
setFilter(newFilter);
// Non-urgent update - can be interrupted
startTransition(() => {
// Expensive filtering operation
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(newFilter.toLowerCase())
);
setUsers(filteredUsers);
});
};
return (
<div>
<input
value={filter}
onChange={(e) => handleFilterChange(e.target.value)}
placeholder="Filter users..."
/>
{isPending && <div>Filtering...</div>}
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
</div>
);
}
2. useDeferredValue - Deferring Expensive Values
useDeferredValue returns a deferred version of a value that may lag behind the original value during urgent updates.
import { useDeferredValue, useMemo, useState } from 'react';
function ExpensiveComponent({ query }: { query: string }) {
// Defer the query value - it will lag behind during urgent updates
const deferredQuery = useDeferredValue(query);
// Expensive computation based on deferred value
const expensiveResults = useMemo(() => {
if (!deferredQuery) return [];
// Simulate expensive computation
return Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i} for "${deferredQuery}"`,
score: Math.random() * 100
})).filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [deferredQuery]);
return (
<div>
<h3>Results for: {deferredQuery}</h3>
<div>
{expensiveResults.map(item => (
<div key={item.id}>
{item.name} (Score: {item.score.toFixed(1)})
</div>
))}
</div>
</div>
);
}
function SearchApp() {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* This component will receive deferred updates */}
<ExpensiveComponent query={query} />
</div>
);
}
// Advanced example with multiple deferred values
function DataVisualization({ data, filters }: { data: any[], filters: any }) {
const deferredData = useDeferredValue(data);
const deferredFilters = useDeferredValue(filters);
const processedData = useMemo(() => {
console.log('Processing data...');
return deferredData
.filter(item => {
if (deferredFilters.category && item.category !== deferredFilters.category) {
return false;
}
if (deferredFilters.status && item.status !== deferredFilters.status) {
return false;
}
return true;
})
.map(item => ({
...item,
processed: expensiveProcessing(item)
}));
}, [deferredData, deferredFilters]);
return (
<div>
<Chart data={processedData} />
</div>
);
}
3. Suspense - Declarative Loading States
Suspense allows components to "suspend" rendering while waiting for data, providing a declarative way to handle loading states.
import { Suspense, lazy, useState } from 'react';
// Lazy load components
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
const UserProfile = lazy(() => import('./UserProfile'));
// Loading fallback components
function ChartSkeleton() {
return (
<div className="skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-chart"></div>
</div>
);
}
function TableSkeleton() {
return (
<div className="skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-rows">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="skeleton-row"></div>
))}
</div>
</div>
);
}
function Dashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<TabNavigation activeTab={activeTab} onTabChange={setActiveTab} />
<div className="content">
{activeTab === 'charts' && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
{activeTab === 'data' && (
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
)}
{activeTab === 'profile' && (
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
)}
</div>
</div>
);
}
// Nested Suspense boundaries
function App() {
return (
<div>
<Header />
<Suspense fallback={<div>Loading main content...</div>}>
<MainContent />
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>
</Suspense>
<Footer />
</div>
);
}
4. Combining Concurrent Features
Here's how to combine all concurrent features for optimal user experience:
import {
useTransition,
useDeferredValue,
Suspense,
useState,
useMemo,
lazy
} from 'react';
const SearchResults = lazy(() => import('./SearchResults'));
function AdvancedSearchApp() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
// Defer the query for expensive operations
const deferredQuery = useDeferredValue(query);
// Expensive search operation
const searchResults = useMemo(() => {
if (!deferredQuery) return [];
// Simulate expensive search
return performExpensiveSearch(deferredQuery);
}, [deferredQuery]);
const handleSearch = (newQuery: string) => {
// Urgent update - input responds immediately
setQuery(newQuery);
// Non-urgent update - can be interrupted
startTransition(() => {
setResults(searchResults);
});
};
return (
<div>
<div className="search-header">
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
className="search-input"
/>
{isPending && (
<div className="search-indicator">
<Spinner />
<span>Searching...</span>
</div>
)}
</div>
<div className="search-content">
<Suspense fallback={<SearchResultsSkeleton />}>
<SearchResults
query={deferredQuery}
results={results}
isPending={isPending}
/>
</Suspense>
</div>
</div>
);
}
// Custom hook combining concurrent features
function useConcurrentSearch<T>(
searchFn: (query: string) => Promise<T[]>,
delay: number = 300
) {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const deferredQuery = useDeferredValue(query);
const handleSearch = (newQuery: string) => {
setQuery(newQuery);
startTransition(async () => {
if (!newQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const searchResults = await searchFn(newQuery);
setResults(searchResults);
} catch (error) {
console.error('Search error:', error);
setResults([]);
} finally {
setIsLoading(false);
}
});
};
return {
query,
deferredQuery,
results,
isPending,
isLoading,
handleSearch
};
}
// Usage
function UserSearch() {
const {
query,
deferredQuery,
results,
isPending,
isLoading,
handleSearch
} = useConcurrentSearch(
async (query) => {
const response = await fetch(`/api/users?search=${query}`);
return response.json();
}
);
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search users..."
/>
{(isPending || isLoading) && <div>Searching...</div>}
<div>
{results.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
</div>
);
}
How Concurrent Features Improve User Experience:
1. Responsive Input Fields:
function ResponsiveInput() {
const [isPending, startTransition] = useTransition();
const [inputValue, setInputValue] = useState('');
const [searchResults, setSearchResults] = useState([]);
const handleInputChange = (value: string) => {
// Urgent update - input responds immediately
setInputValue(value);
// Non-urgent update - search can be interrupted
startTransition(() => {
performSearch(value).then(setSearchResults);
});
};
return (
<div>
<input
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="Type to search..."
/>
{/* Input remains responsive even during search */}
{isPending && <div>Searching...</div>}
<SearchResults results={searchResults} />
</div>
);
}
2. Smooth Navigation:
function App() {
const [isPending, startTransition] = useTransition();
const [currentPage, setCurrentPage] = useState('home');
const navigateTo = (page: string) => {
// Urgent update - navigation happens immediately
setCurrentPage(page);
// Non-urgent update - page content loads in background
startTransition(() => {
// Preload page content
preloadPageContent(page);
});
};
return (
<div>
<Navigation
currentPage={currentPage}
onNavigate={navigateTo}
/>
{isPending && <PageTransitionIndicator />}
<Suspense fallback={<PageSkeleton />}>
<PageContent page={currentPage} />
</Suspense>
</div>
);
}
3. Optimistic Updates:
function OptimisticTodoList() {
const [isPending, startTransition] = useTransition();
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
optimistic: true // Mark as optimistic
};
// Optimistic update - UI updates immediately
setTodos(prev => [...prev, newTodo]);
// Non-urgent update - sync with server
startTransition(async () => {
try {
const savedTodo = await saveTodo(text);
setTodos(prev =>
prev.map(todo =>
todo.id === newTodo.id
? { ...savedTodo, optimistic: false }
: todo
)
);
} catch (error) {
// Revert optimistic update on error
setTodos(prev => prev.filter(todo => todo.id !== newTodo.id));
}
});
};
return (
<div>
<TodoForm onSubmit={addTodo} />
{isPending && <div>Syncing...</div>}
<TodoList todos={todos} />
</div>
);
}
4. Progressive Loading:
function ProgressiveDataLoader() {
const [isPending, startTransition] = useTransition();
const [data, setData] = useState<any[]>([]);
const [page, setPage] = useState(1);
const loadMoreData = () => {
startTransition(async () => {
const newData = await fetchData(page + 1);
setData(prev => [...prev, ...newData]);
setPage(prev => prev + 1);
});
};
return (
<div>
<DataList data={data} />
{isPending && <LoadingIndicator />}
<button onClick={loadMoreData}>
Load More
</button>
</div>
);
}
Key Benefits:
- Immediate Responsiveness: Urgent updates (like input changes) happen immediately
- Non-blocking Updates: Expensive operations don't block the UI
- Better Perceived Performance: Users see immediate feedback
- Smoother Animations: Transitions can be interrupted without jarring effects
- Progressive Enhancement: Content loads progressively as it becomes available
- Optimistic Updates: UI can update optimistically while syncing in background
Best Practices:
- Use useTransition for expensive operations
- Use useDeferredValue for values that can lag behind
- Wrap lazy-loaded components in Suspense
- Provide meaningful loading fallbacks
- Combine features for optimal user experience
- Test with slow devices and networks
These concurrent features work together to create a more responsive and fluid user experience, especially in applications with complex state updates and data fetching.