Skip to main content

useState and useEffect

useState - Managing State

The useState hook allows you to add state to functional components.

Basic Usage

import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

TypeScript with useState

// Primitive types (inferred)
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isActive, setActive] = useState(false); // boolean

// Complex types (explicit)
interface User {
id: string;
name: string;
email: string;
}

const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);

// With initial value
const [user, setUser] = useState<User>({
id: '1',
name: 'John',
email: 'john@example.com',
});

Initializer Function

For expensive initial state calculations:

// ❌ Bad - runs on every render
const [state, setState] = useState(expensiveCalculation());

// ✅ Good - runs only once
const [state, setState] = useState(() => expensiveCalculation());

// Example: Reading from localStorage
const [data, setData] = useState(() => {
const saved = localStorage.getItem('data');
return saved ? JSON.parse(saved) : [];
});

Functional Updates

When new state depends on previous state:

// ❌ Can cause issues with async updates
const increment = () => {
setCount(count + 1);
setCount(count + 1); // Both use the same count value!
};

// ✅ Always use functional updates
const increment = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Correctly increments by 2
};

// Complex example
const addItem = (item: Item) => {
setItems(prevItems => [...prevItems, item]);
};

const removeItem = (id: string) => {
setItems(prevItems => prevItems.filter(item => item.id !== id));
};

const updateItem = (id: string, updates: Partial<Item>) => {
setItems(prevItems =>
prevItems.map(item => (item.id === id ? { ...item, ...updates } : item))
);
};

Multiple State Variables

function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);

// Or use a single state object
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
age: 0,
});

const updateField = (field: string, value: any) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
}

State Object Updates

interface FormState {
username: string;
email: string;
password: string;
}

function RegistrationForm() {
const [form, setForm] = useState<FormState>({
username: '',
email: '',
password: '',
});

// Update single field
const updateUsername = (username: string) => {
setForm(prev => ({ ...prev, username }));
};

// Generic update function
const updateField = (field: keyof FormState, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
};

// Handle input change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};

return (
<form>
<input name='username' value={form.username} onChange={handleChange} />
<input name='email' value={form.email} onChange={handleChange} />
<input
name='password'
type='password'
value={form.password}
onChange={handleChange}
/>
</form>
);
}

useEffect - Side Effects

The useEffect hook lets you perform side effects in function components.

Basic Usage

import { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// Runs after every render
useEffect(() => {
document.title = `Count: ${count}`;
});

return <button onClick={() => setCount(count + 1)}>Click</button>;
}

Dependency Array

// Runs only once on mount
useEffect(() => {
console.log('Component mounted');
}, []);

// Runs when count changes
useEffect(() => {
console.log('Count changed:', count);
}, [count]);

// Runs when any dependency changes
useEffect(() => {
console.log('User or settings changed');
}, [user, settings]);

// ❌ Missing dependencies (ESLint warning)
useEffect(() => {
console.log(count); // count should be in deps
}, []);

// ✅ Include all dependencies
useEffect(() => {
console.log(count);
}, [count]);

Cleanup Function

useEffect(() => {
// Setup
const timer = setInterval(() => {
console.log('Tick');
}, 1000);

// Cleanup
return () => {
clearInterval(timer);
};
}, []);

// Event listeners
useEffect(() => {
const handleResize = () => {
console.log('Window resized');
};

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

// Subscriptions
useEffect(() => {
const subscription = observable.subscribe(data => {
setData(data);
});

return () => {
subscription.unsubscribe();
};
}, [observable]);

Data Fetching

function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
let cancelled = false;

async function fetchUser() {
try {
setLoading(true);
setError(null);

const response = await fetch(`/api/users/${userId}`);
const data = await response.json();

if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err as Error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}

fetchUser();

return () => {
cancelled = true; // Prevent state updates if unmounted
};
}, [userId]);

if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
if (!user) return <NotFound />;

return <UserCard user={user} />;
}

Using AbortController

useEffect(() => {
const controller = new AbortController();

async function fetchData() {
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
const data = await response.json();
setData(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
}
}

fetchData();

return () => {
controller.abort();
};
}, []);

Multiple Effects

Separate concerns into different effects:

function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);

// Effect 1: Fetch user
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);

// Effect 2: Fetch posts
useEffect(() => {
if (user) {
fetchPosts(user.id).then(setPosts);
}
}, [user]);

// Effect 3: Subscribe to notifications
useEffect(() => {
if (user) {
const unsubscribe = subscribeToNotifications(user.id, setNotifications);
return unsubscribe;
}
}, [user]);

// Effect 4: Update document title
useEffect(() => {
document.title = user ? `${user.name}'s Dashboard` : 'Dashboard';
}, [user]);

return <div>{/* Render dashboard */}</div>;
}

Common Patterns

1. Local Storage Sync

function useLocalStorageState\<T\>(key: string, initialValue: T) {
const [state, setState] = useState\<T\>(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
});

useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);

return [state, setState] as const;
}

// Usage
const [theme, setTheme] = useLocalStorageState('theme', 'light');
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);

useEffect(() => {
const timer = setTimeout(() => {
if (query) {
searchAPI(query).then(setResults);
}
}, 500); // Debounce for 500ms

return () => clearTimeout(timer);
}, [query]);

return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder='Search...'
/>
<ResultsList results={results} />
</div>
);
}

3. Window Size Tracking

function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});

useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};

window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

return size;
}

// Usage
function Component() {
const { width } = useWindowSize();
const isMobile = width < 768;

return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}

4. Document Title

function useDocumentTitle(title: string) {
useEffect(() => {
const prevTitle = document.title;
document.title = title;

return () => {
document.title = prevTitle;
};
}, [title]);
}

// Usage
function UserProfile({ user }: { user: User }) {
useDocumentTitle(`${user.name}'s Profile`);
return <div>{/* Profile content */}</div>;
}

5. Interval/Timer

function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);

useEffect(() => {
savedCallback.current = callback;
}, [callback]);

useEffect(() => {
if (delay === null) return;

const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}

// Usage
function Clock() {
const [time, setTime] = useState(new Date());

useInterval(() => {
setTime(new Date());
}, 1000);

return <div>{time.toLocaleTimeString()}</div>;
}

Common Pitfalls

1. Infinite Loops

// ❌ Infinite loop - sets state on every render
useEffect(() => {
setCount(count + 1);
}); // No dependency array!

// ❌ Infinite loop - object/array in dependency
useEffect(() => {
fetchData(config); // config is new object every render
}, [config]);

// ✅ Use useMemo or move config inside effect
const config = useMemo(() => ({ url: '/api' }), []);
useEffect(() => {
fetchData(config);
}, [config]);

2. Stale Closures

// ❌ Stale closure - always shows 0
function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always 0!
setCount(count + 1); // Always sets to 1!
}, 1000);

return () => clearInterval(timer);
}, []); // Empty deps means count is captured once

return <div>{count}</div>;
}

// ✅ Use functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);

return () => clearInterval(timer);
}, []);

3. Race Conditions

// ❌ Race condition
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// If userId changes quickly, responses may arrive out of order

// ✅ Handle cancellation
useEffect(() => {
let cancelled = false;

fetchUser(userId).then(data => {
if (!cancelled) {
setUser(data);
}
});

return () => {
cancelled = true;
};
}, [userId]);

4. Missing Dependencies

// ❌ Missing dependencies
function SearchResults({ query }) {
const [results, setResults] = useState([]);

useEffect(() => {
search(query).then(setResults);
}, []); // query should be in deps!

return <div>{/* results */}</div>;
}

// ✅ Include all dependencies
useEffect(() => {
search(query).then(setResults);
}, [query]);

Best Practices

  1. One effect per concern - Don't mix unrelated logic
  2. Always include dependencies - Use ESLint rule
  3. Clean up subscriptions - Prevent memory leaks
  4. Use functional updates - When state depends on previous state
  5. Handle async properly - Avoid race conditions
  6. Extract custom hooks - Reuse effect logic
  7. Avoid objects/arrays in deps - Use useMemo or primitives

Practice Exercises

  1. Create a counter with auto-increment/decrement
  2. Build a real-time search with debouncing
  3. Implement a timer/stopwatch component
  4. Create a component that fetches and displays user data
  5. Build a form that auto-saves to localStorage

Next Steps