Doing React the Right Way - 4/10
useState, useEffect, and the Art of Managing State
Your React components so far have been stateless—they receive props, render JSX, and that’s it. They’re pure functions with no memory of previous interactions and no ability to change over time. While this makes them predictable and easy to understand, it’s not very useful for building interactive applications.
Today, we’re giving your components a memory and the ability to interact with the outside world. We’ll explore useState for managing component state, useEffect for handling side effects, and useTransition for keeping your UI responsive during heavy operations.
These aren’t just React features—they represent fundamental concepts in modern UI development that you’ll use in every interactive component you build.
useState: Giving Components Memory
State is any data that a component “owns” and that can change over time as users interact with your application. When state changes, React automatically re-renders the component to reflect those changes. The useState
hook is how you add state to functional components.
Let’s build the classic counter example:
// src/components/Counter.tsx
import { useState } from "react";
function Counter() {
// Declare a state variable called "count" with initial value 0
const [count, setCount] = useState<number>(0);
const handleIncrement = () => {
setCount(count + 1);
};
const handleDecrement = () => {
setCount(count - 1);
};
return (
<div>
<p>Current count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
<button onClick={handleDecrement}>Decrement</button>
</div>
);
}
export default Counter;
Let’s examine the useState
line:
const [count, setCount] = useState<number>(0);
useState<number>(0)
: Calls theuseState
hook with an initial value of0
. The TypeScript generic<number>
tells the hook this state will always be a numberuseState
returns an array with exactly two elements, which we destructure:count
: The current state value—a read-only snapshot for this rendersetCount
: The setter function—the only way to update the state
The Immutability Principle
React has one non-negotiable rule about state: never modify state directly. Always use the setter function to create new state values. This is especially important with objects and arrays:
// Managing a list of tasks
function TaskList() {
const [tasks, setTasks] = useState<string[]>(["Learn React", "Build an app"]);
const addTask_WRONG = () => {
// WRONG: This mutates the existing array
tasks.push("Deploy to production");
setTasks(tasks); // React won't detect this change!
};
const addTask_CORRECT = () => {
// CORRECT: Create a new array with the spread operator
setTasks([...tasks, "Deploy to production"]);
};
return (
<div>
<ul>
{tasks.map((task, index) => (
<li key={index}>{task}</li>
))}
</ul>
<button onClick={addTask_CORRECT}>Add Task</button>
</div>
);
}
Why is immutability so important? React optimizes performance by doing shallow comparisons of state values. When you mutate an array with push()
, you’re changing the array’s contents but not creating a new array object. React sees the same object reference and assumes nothing changed, skipping the re-render.
When you create a new array with the spread operator, React sees a different object reference and knows to re-render the component.
This principle of treating state as read-only ensures predictable behavior and enables React’s performance optimizations.
useEffect: Managing Side Effects
Your component’s main function should be pure—given the same props and state, it should always return the same JSX. But real applications need to interact with the outside world: fetch data from APIs, set up subscriptions, manipulate the document title, and more.
These side effects don’t belong in your component’s render logic. That’s where useEffect
comes in—it’s a designated space where you can safely perform side effects after React has finished rendering.
import { useState, useEffect } from "react";
function DocumentTitleUpdater() {
const [count, setCount] = useState(0);
// This side effect runs after every render
useEffect(() => {
document.title = `Count: ${count}`;
});
return (
<button onClick={() => setCount(count + 1)}>
Update count (currently {count})
</button>
);
}
The useEffect
hook ensures that DOM manipulation happens after React finishes its rendering work, preventing interference with React’s rendering process.
The Dependency Array: Controlling When Effects Run
useEffect
accepts a second argument—the dependency array. This array determines when your effect should run and is the source of many React bugs when misunderstood.
There are three patterns:
1. No Dependency Array (Run Every Render)
useEffect(() => {
console.log("This runs after every render");
});
Use case: Almost never. This usually indicates a bug. If your effect also updates state, you’ll create an infinite loop.
2. Empty Dependency Array (Run Once)
useEffect(() => {
console.log("This runs once after the initial render");
}, []);
Use case: One-time setup operations like initial data fetching, setting up subscriptions, or configuring third-party libraries.
3. Dependencies Array (Run When Dependencies Change)
useEffect(() => {
console.log("This runs when userId changes");
fetchUserData(userId);
}, [userId]);
Use case: When your effect needs to react to changes in props or state. The effect re-runs whenever any value in the dependency array changes since the last render.
Cleanup Functions
Effects often need cleanup to prevent memory leaks. Return a function from your effect to handle cleanup:
useEffect(() => {
// Set up a timer
const timerId = setInterval(() => {
console.log("Timer tick");
}, 1000);
// Cleanup function - runs when component unmounts
// or before the effect runs again
return () => {
console.log("Cleaning up timer");
clearInterval(timerId);
};
}, []); // Empty array means setup once, cleanup once
React calls the cleanup function:
- When the component unmounts
- Before running the effect again (if dependencies changed)
When NOT to Use useEffect
A common beginner mistake is overusing useEffect
. Here are scenarios where you probably don’t need it:
Computing Derived State
// BAD: Using effect for computed values
function UserProfile({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) {
const [fullName, setFullName] = useState("");
// This is inefficient and unnecessary
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
// GOOD: Compute during render
function UserProfile({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) {
const fullName = `${firstName} ${lastName}`;
return <div>{fullName}</div>;
}
Event-Driven Logic
Don’t use effects for logic that should respond to user events:
// GOOD: Handle user events directly
function SearchComponent() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleSearch = async () => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
};
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={handleSearch}>Search</button>
</div>
);
}
Professional Data Fetching Pattern
Here’s a robust pattern for fetching data with proper loading states, error handling, and cleanup:
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// AbortController enables request cancellation
const controller = new AbortController();
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const userData: User = await response.json();
setUser(userData);
} catch (err) {
// Only set error if request wasn't aborted
if (err.name !== "AbortError") {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
setLoading(false);
}
};
fetchUser();
// Cleanup: cancel the request if component unmounts
// or userId changes before request completes
return () => {
controller.abort();
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading user data...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
This pattern handles the three essential states of async operations: loading, success, and error. The cleanup function prevents race conditions and memory leaks.
useTransition: Keeping Your UI Responsive
Modern React includes Concurrent Features that prevent heavy operations from blocking user interactions. The useTransition
hook is your gateway to these capabilities.
The Problem: UI Blocking
Imagine a search input that filters a large list. In traditional React, every keystroke would:
- Update the input state
- Trigger a re-render of thousands of list items
- Block the entire UI until rendering completes
The result is a janky experience where the UI freezes between keystrokes.
The Solution: Concurrent Rendering
useTransition
allows React to keep the UI responsive by making some state updates “non-urgent”:
import { useState, useTransition } from "react";
// Generate test data
const generateItems = (count: number) =>
Array.from({ length: count }, (_, i) => `Item ${i + 1}`);
const allItems = generateItems(20000);
function FilterableList() {
const [isPending, startTransition] = useTransition();
const [inputValue, setInputValue] = useState(""); // Urgent state
const [filterQuery, setFilterQuery] = useState(""); // Non-urgent state
const filteredItems = allItems.filter((item) =>
item.toLowerCase().includes(filterQuery.toLowerCase())
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Urgent update: input field stays responsive
setInputValue(value);
// Non-urgent update: filtering can be interrupted
startTransition(() => {
setFilterQuery(value);
});
};
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
placeholder="Filter items..."
/>
{isPending && <p>Updating results...</p>}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ul>
{filteredItems.slice(0, 100).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p>
Showing {Math.min(100, filteredItems.length)} of{" "}
{filteredItems.length} results
</p>
</div>
</div>
);
}
How useTransition Works
useTransition
returns:
isPending
: Boolean indicating if a transition is in progressstartTransition
: Function to wrap non-urgent state updates
When you wrap a state update in startTransition
, you’re telling React: “This update isn’t urgent. Feel free to pause this work if more important things (like user input) need to happen.”
The result is a UI that stays responsive even during heavy operations. Users can continue typing while React works on filtering the list in the background.
The State Management Foundation
You now have the essential tools for building interactive React applications:
useState: Adds memory to your components, enabling them to respond to user interactions and change over time
useEffect: Provides a safe space for side effects like data fetching, subscriptions, and DOM manipulation
useTransition: Keeps your UI responsive during heavy operations by separating urgent updates from non-urgent ones
These hooks represent fundamental patterns in modern UI development:
- State management: Tracking data that changes over time
- Side effect management: Safely interacting with systems outside React
- Performance optimization: Keeping interfaces responsive under load
Best Practices Summary
- Always use the setter function to update state—never mutate state directly
- Treat state as immutable—create new objects/arrays rather than modifying existing ones
- Use the dependency array correctly in useEffect—include all values from component scope that the effect uses
- Prefer computing derived values during render over storing them in state
- Use useTransition for heavy, non-urgent operations to maintain UI responsiveness
- Always clean up side effects to prevent memory leaks
In our next article, we’ll explore more advanced React patterns: custom hooks, context for sharing state, and performance optimization techniques. But the foundation you’ve built with useState, useEffect, and useTransition will support everything else you learn.
You’re no longer building static components—you’re building dynamic, interactive user interfaces that respond to user input and external data. That’s the difference between knowing React syntax and thinking in React patterns.