Clean Code: Say What You Want (Declarative Programming)
Imagine you're in a taxi. You can either give the driver turn-by-turn directions ("take the next left, then the third right..."), or you can just tell them your destination. The first approach is imperative—you're describing the steps to get there. The second is declarative—you're describing the desired result, and you trust the driver to handle the details.
In programming, we often start by writing imperative code. But as we've seen with libraries like React and the rise of functional programming in JavaScript, the trend is moving towards a more declarative style.
From Imperative Loops to Declarative Chains
A classic example is transforming data in an array. The imperative approach uses a for
loop to manually iterate and build a new array. The declarative approach uses array methods like filter
and map
to describe the transformation.
// Bad: The imperative approach describes every step of the process.
function getActiveUserNames(users: User[]): string[] {
const activeUsers = [];
for (let i = 0; i < users.length; i++) {
if (users[i].isActive) {
activeUsers.push(users[i].name);
}
}
return activeUsers;
}
// Good: The declarative approach describes the result we want.
function getActiveUserNames(users: User[]): string[] {
return users
.filter(user => user.isActive)
.map(user => user.name);
}
The declarative version is more concise and arguably easier to read. It tells us what we want (the names of active users), not how to get it.
Declarative UIs with React
Modern UI libraries like React are built around the idea of declarative programming. You describe what the UI should look like for a given state, and React takes care of the "how"—updating the DOM efficiently.
// Bad: An imperative-style React component. We're manually managing the filtered list.
function UserList({ users }: { users: User[] }) {
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const filtered = [];
for (let i = 0; i < users.length; i++) {
if (users[i].name.toLowerCase().includes(searchTerm.toLowerCase())) {
filtered.push(users[i]);
}
}
setFilteredUsers(filtered);
}, [users, searchTerm]);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{filteredUsers.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
// Good: A declarative React component. We describe what `filteredUsers` should be, and React handles the rest.
function UserList({ users }: { users: User[] }) {
const [searchTerm, setSearchTerm] = useState('');
// We declare that filteredUsers is the users array filtered by the search term.
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{/* We just render the filtered list. React handles the updates. */}
{filteredUsers.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
The declarative approach often leads to code that is more predictable and easier to debug because you're not managing all the nitty-gritty state changes yourself.
Writing declarative code is a shift in mindset. It's about focusing on the end result and letting the tools and abstractions handle the implementation details. This often leads to code that is not only cleaner but also more robust and maintainable.