Thinking Declaratively in React
Guide on thinking in declarative and state-driven approaches in React, featuring practical examples like a todo list to illustrate building dynamic, maintainable UIs
One of the core principles of React is its declarative nature. Instead of manually updating the DOM step by step (imperative programming), React allows you to define what the UI should look like based on the current state, and it takes care of the updates for you. This approach makes UI development more predictable, scalable, and easier to reason about.
Declarative UI vs imperative UI
In imperative programming, you give explicit instructions on how things should happen. DOM APIs are inherently imperative. When manipulating the DOM, this often means selecting elements and modifying them manually.
const button = document.createElement('button');button.textContent = 'Click me';button.style.backgroundColor = 'blue';document.body.appendChild(button);button.addEventListener('click', () => {button.style.backgroundColor = 'red';alert('Button clicked!');});
See that every single action is explicitly defined: creating the button, setting styles, appending it to the DOM, and changing properties in response to an event.
Declarative programming, on the other hand, focuses on describing the desired outcome rather than detailing how to achieve it. React components allow us to declare what the UI should look like, and React takes care of updating it when state changes.
function App() {const [color, setColor] = React.useState('blue');return (<button style={{ backgroundColor: color }} onClick={() => setColor('red')}>Click me</button>);}
In this example, we define the button's UI based on color
state. When the state changes, React automatically updates the DOM without us having to manage it manually.
Analogy: Imperative vs Declarative
Think of making a cup of coffee:
- Imperative: "Take a mug, pour hot water, add coffee, stir, and serve"
- Declarative: "I want a cup of coffee"
In a declarative approach, the details of execution are abstracted away. You describe the final outcome, and a system (e.g. a coffee machine, a barista, or React) ensures it happens correctly.
The benefit of declarative programming might not be too obvious in the button example above because it is small.
Let's use a slightly more complex example of a todo list that allows adding, deleting, and completing of tasks. The UI should show the task list and the total number of tasks as well as completed tasks.
Using an imperative approach (e.g., using vanilla JavaScript and the DOM API), every interaction requires manually finding, updating, and re-rendering elements.
The following user actions will require these DOM operations:
- Adding a task:
- Append the new task to the existing list
- Clear the contents of the input
- Add event listener to the newly-added task for task completion
- Increment the number of total tasks
- Completing a task:
- Update the task to show completed state
- Modify the number of completed tasks
- Deleting a task:
- Remove the task from the list
- Decrement the number of total tasks
- Decrement the number of completed tasks if the task was completed
Using an imperative approach, it is much tougher to keep the UI in-sync with state as you have to remember the relevant areas to modify. When new features are introduced, imperatively-written logic becomes harder to read and trace. That's bad for maintainability!
Using a declarative approach such as React, you can simply describe the UI that should be shown based on the updated tasks state, and React will figure out the necessary imperative DOM operations to evolve the current UI into the intended UI.
There can be infinitely many possible current UIs – how does React figure out the right imperative DOM operations to make? That's where React's virtual DOM and reconciliation process comes in. React compares/diffs the current UI representation with the new UI representation, and generates the necessary list of DOM operations.
Declarative UI is the approach taken by virtually any modern UI framework due to its overwhelming advantages over the imperative approach.
Further reading on react.dev: How declarative UI compares to imperative
How to think about UI declaratively
Thinking declaratively requires shifting your focus from how to update the UI to what the UI should be at any given moment.
Let's use the same todo list example above and demonstrate how declarative programming is better. Instead of manually manipulating the DOM (imperative programming), we define how the UI should look based on state and let React take care of the rendering.
To make things a bit more complex, the todo list supports filtering (All, Complete, Incomplete).
1. Identify visual states in the components
A todo list has several possible UI states:
- Input field that can accept text input from the user
- A list of tasks, which can be empty or non-empty
- Each task can be completed or incomplete
- Selector for task filters and the selected option
- The list of tasks can be filtered (All, Active, Completed)
2. Determine the actions that trigger state changes
Next, we define the actions that affect state:
- Adding a task
- Completing a task
- Deleting a task
- Filtering tasks (show All, Active, or Completed)
These actions will modify state, and React will automatically re-render the UI accordingly.
3. Design a minimal structure to represent the state
Next, we need to design a structure to capture the state data in the UI.
Here's one possible design:
const [tasks, setTasks] = useState([{ id: 1, title: 'Learn React' },{ id: 2, title: 'Build a project' },]);const [completedTasks, setCompletedTasks] = useState([{ id: 1, title: 'Learn React' },]);const [incompleteTasks, setIncompleteTasks] = useState([{ id: 2, title: 'Build a project' },]);const [filter, setFilter] = useState('all'); // all | active | completed
However, in this design, there's some duplication of data. Tasks are part of both tasks
and either completedTasks
or incompleteTasks
, and adding/remove tasks will involve modifying multiple tasks arrays. This state design is not great.
Some ways to improve it:
- The
incompleteTasks
state is redundant since if a task is not withincompletedTasks
, then it is incomplete. However, it would be inefficient when linear scanning thecompletedTasks
array for every task to check if a task is completed - We can use a
Set
forcompletedTasks
that stores only the task ID. This way, the task titles don't have to be repeated and looking up a task's completion status is efficient. However, we still need to remember to modify two places when a completed task is being deleted.
Instead of storing multiple independent pieces of state that require syncing, we can track completion status on the task itself. Here's a minimal and structured state representation:
const [tasks, setTasks] = useState([{ id: 1, title: 'Learn React', completed: false },{ id: 2, title: 'Build a project', completed: true },]);const [filter, setFilter] = useState('all'); // all | active | completed
Using this structure:
tasks
is an array where each task has anid
,title
, andcompleted
statusfilter
determines which tasks to display
To avoid redundant state, instead of keeping a separate completedTasks
array, we can derive completed tasks from tasks
.
There are tradeoffs to consider here. Although state is now consolidated, toggling tasks completion will now require cloning of the entire tasks
array when there wasn't a need to if we use a Set
for completedTasks
.
4. Call actions within event handlers
Actions are triggered in response to two kinds of events:
- User events: Actions directly performed by the user, such as clicking a button, typing in an input field, or selecting an option from a dropdown
- Background events: Actions triggered without direct user interaction such as API responses, timers and intervals, WebSocket real-time updates
For the todo list at hand, we only need to care about user events.
However, it is advisable to write a function for each of these actions, and call the state setters within each action function. This is because the same operation can be triggered from many places on the UI, or even in the background. An example is a video player where there to pause the video, users can either press the "Pause" button or press the spacebar.
Centralizing state update logic within these action functions will help to keep the code maintainable.
Full example
Now, let's implement what we have learnt above.
import { useState } from 'react';function TodoApp() {const [tasks, setTasks] = useState([]);const [filter, setFilter] = useState('all');const [taskInput, setTaskInput] = useState('');// Add a taskfunction addTask() {if (taskInput.trim() === '') {return;}const newTask = { id: Date.now(), text: taskInput, completed: false };setTasks([...tasks, newTask]);setTaskInput('');}// Toggle task completionfunction toggleTask(id) {setTasks(tasks.map((task) =>task.id === id ? { ...task, completed: !task.completed } : task,),);}// Delete a taskfunction deleteTask(id) {setTasks(tasks.filter((task) => task.id !== id));}// Get tasks based on filterconst filteredTasks = tasks.filter((task) => {if (filter === 'active') {return !task.completed;}if (filter === 'completed') {return task.completed;}return true; // "all" case});return (<div><h2>Todo List</h2><inputvalue={taskInput}onChange={(e) => setTaskInput(e.target.value)}placeholder="Add a new task..."/><button onClick={addTask}>Add</button><div><button onClick={() => setFilter('all')}>All</button><button onClick={() => setFilter('active')}>Active</button><button onClick={() => setFilter('completed')}>Completed</button></div><ul>{filteredTasks.map((task) => (<likey={task.id}style={{textDecoration: task.completed ? 'line-through' : 'none',}}>{task.text}<button onClick={() => toggleTask(task.id)}>{task.completed ? 'Undo' : 'Complete'}</button><button onClick={() => deleteTask(task.id)}>❌</button></li>))}</ul></div>);}export default TodoApp;
This todo list is declarative because:
- UI updates automatically: When
tasks
orfilter
changes, React re-renders the UI accordingly - No manual DOM manipulation: No need to select elements, manually remove tasks, or update styles via
document.querySelector
. - Minimal and structured state: The UI is derived from a single source of truth (
tasks
andfilter
) - Event handlers update state, not the DOM: The functions
addTask
,toggleTask
, anddeleteTask
only update state, React handles the re-rendering.
This example shows how thinking declaratively simplifies UI development in React:
- Identify UI states
- Determine state-changing actions/operations
- Design a minimal state structure
- Use event handlers to update state
Instead of micromanaging DOM updates, we let React react to state changes – making our code cleaner, scalable, and easier to maintain.
Thinking declaratively in React means shifting from a step-by-step, command-driven mindset to a state-driven approach. By focusing on defining what the UI should be based on state, you create components that are more readable, predictable, and maintainable.
Embracing this paradigm makes it easier to manage complex UIs and lets React do the heavy lifting of figuring out how to update the DOM efficiently.
What you need to know for interviews
- Think declaratively and design the component: Given a UI and the requirements, you should be able to think in a declarative fashion to define the various necessary components, props, state, operations that change the state, and connect all of them together.
Practice questions
Coding: