Introduction
There is a moment in most developers’ careers where they look at code they wrote twelve months ago and feel something close to embarrassment. Not because it was wrong — it probably worked — but because of how much effort it took to say something simple. How much it explained itself step by step when it could have just declared what it wanted. How much it described the journey when the destination was all that mattered.
That feeling, when you can articulate it, is usually the beginning of understanding the difference between imperative and declarative code.
I have been thinking about this distinction for a long time — across Angular and React codebases, across the plain JavaScript utility modules and CSS architecture decisions that underpin all of it. The shift from imperative to declarative thinking is not something that happens once. It happens gradually, in layers, as each domain you work in reveals a new version of the same insight. You learn it in JavaScript array methods and then you learn it again in RxJS and then you learn it again in CSS and then you learn it again in component architecture and each time it feels both familiar and new.
This is what that distinction is, why it matters, and what it does to a project over time.
The Basic Distinction
Let me start with the clearest possible version of the difference, before the nuance.
Imperative code describes how to do something. It gives the computer a sequence of steps and trusts that following those steps will produce the desired result. It is control flow expressed explicitly — loops, conditionals, variable mutations, step by step.
Declarative code describes what you want. It expresses the desired outcome and leaves the how to the language, the framework, or the runtime. It is intent expressed directly, without the machinery of execution.
A trivial example, before the real ones:
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Imperative — describes how
const evenSquaresImperative = [];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
evenSquaresImperative.push(numbers[i] * numbers[i]);
}
}
// Declarative — describes what
const evenSquaresDeclarative = numbers
.filter((n) => n % 2 === 0)
.map((n) => n * n);
Both produce [4, 16, 36, 64, 100]. But the declarative version reads like an English sentence: give me the numbers that are even, then square them. The imperative version reads like a recipe: create an empty array, iterate from zero to the length, check if the current item satisfies a condition, if it does, compute a value and push it.
The imperative version is not wrong. For a developer new to JavaScript, it might actually be clearer — they can trace through the steps and predict the outcome. But for a developer who has absorbed the vocabulary of array methods, the declarative version communicates the intent faster, with less noise, and with fewer opportunities to introduce bugs.
That trade — more vocabulary required upfront, less cognitive load in reading — is the fundamental trade of declarative programming. It almost always favours the declarative as codebases grow and the cost of reading and understanding code starts to exceed the cost of writing it.
Where It Shows Up in Real Projects
The basic array method example is clean but small. The distinction becomes more consequential in larger contexts, and I want to walk through several domains where I have seen it matter.
DOM Manipulation — jQuery vs Modern JavaScript
Before frameworks abstracted the DOM, the choice between imperative and declarative styles was explicit in how you manipulated the page.
// Imperative — jQuery style
// "Find the list, loop through its children,
// check the status, and add or remove the class manually"
$('#terminal-list li').each(function () {
if ($(this).data('status') === 'offline') {
$(this).addClass('terminal--offline');
$(this).removeClass('terminal--online');
} else {
$(this).addClass('terminal--online');
$(this).removeClass('terminal--offline');
}
});
// Declarative — modern approach
// "The class reflects the status. Keep them in sync."
terminals.forEach((terminal) => {
const el = document.getElementById(`terminal-${terminal.id}`);
el.classList.toggle('terminal--offline', terminal.status === 'offline');
el.classList.toggle('terminal--online', terminal.status === 'online');
});
The second version is still somewhat imperative — we’re still manually touching the DOM — but classList.toggle declares the intent more directly: this class should be present if this condition is true. The first version describes a decision tree. The second version describes a rule.
The real declarative shift comes with frameworks, which take this further:
<!-- Angular template — fully declarative -->
<!-- "This element's class depends on this condition.
Angular keeps it in sync." -->
<li
*ngFor="let terminal of terminals"
[class.terminal--offline]="terminal.status === 'offline'"
[class.terminal--online]="terminal.status === 'online'"
>
{{ terminal.id }}
</li>
Now there is no imperative code at all. You have declared the relationship between the data and the DOM, and Angular manages the synchronisation. When terminal.status changes, the class changes. You did not write that update logic. You declared the rule and delegated the execution.
This is the core power of declarative frameworks: they let you describe the desired state of the UI as a function of the application state, and handle the mechanics of keeping them synchronised.
State Management — Mutation vs Transformation
One of the most consequential places the imperative/declarative distinction shows up in real projects is in how state is managed.
Imperative state management mutates things:
// Imperative — mutate the existing state
class TicketService {
tickets: Ticket[] = [];
resolveTicket(id: string) {
// Find the ticket, mutate it, manually notify
for (let i = 0; i < this.tickets.length; i++) {
if (this.tickets[i].id === id) {
this.tickets[i].status = 'resolved';
this.tickets[i].resolvedAt = new Date();
break;
}
}
this.notifyUpdate(); // easy to forget, silent failure if you do
}
addTicket(ticket: Ticket) {
this.tickets.push(ticket); // mutates in place
this.notifyUpdate();
}
}
Declarative state management produces new state from old state:
// Declarative — describe what the new state should look like
// given the old state and the action that occurred
function ticketReducer(state: Ticket[], action: TicketAction): Ticket[] {
switch (action.type) {
case 'RESOLVE':
// "The new state is the same as the old state,
// except this ticket has a different status"
return state.map((ticket) =>
ticket.id === action.id
? { ...ticket, status: 'resolved', resolvedAt: new Date() }
: ticket
);
case 'ADD':
// "The new state is the old state with this ticket added"
return [...state, action.ticket];
default:
return state;
}
}
The reducer is a pure function. Given the same state and the same action, it always produces the same result. It has no side effects. It does not know about notifications, about Angular’s change detection, about React’s re-render cycle. It just describes what the new state should be.
This is not just cleaner. It is fundamentally more testable, more predictable, and more debuggable. On the FedEx ECAM project, moving away from mutable service state toward this kind of declarative state transformation was one of the changes that most significantly reduced the number of bugs related to the UI being out of sync with the underlying data.
RxJS — The Declarative Pipeline
RxJS is where the declarative paradigm either clicks or doesn’t, and in my experience, it is the thing most Angular developers struggle with longest.
The struggle is almost always about trying to use RxJS imperatively — reaching into the stream at specific moments, managing subscriptions manually, mixing Observables with side-effecting code in ways that defeat the purpose of the abstraction.
// Imperative approach to a reactive problem
// "I'll manually manage the subscription,
// the loading state, the error state, and the cleanup"
class TerminalComponent implements OnInit, OnDestroy {
terminals: Terminal[] = [];
loading = true;
error: string | null = null;
private subscription: Subscription;
ngOnInit() {
this.subscription = this.terminalService.getTerminals().subscribe({
next: (data) => {
this.terminals = data;
this.loading = false;
},
error: (err) => {
this.error = err.message;
this.loading = false;
},
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
// Declarative approach — describe what the stream should be
// Angular's async pipe handles subscription, unsubscription,
// and change detection automatically
class TerminalComponent {
terminals$ = this.terminalService.getTerminals().pipe(
// "Start in loading state, then emit the data"
startWith(null),
// "If something goes wrong, emit an error state"
catchError((err) => of({ error: err.message }))
);
// Template uses async pipe:
// *ngIf="terminals$ | async as result"
}
The declarative version describes the stream of states the component can be in. It does not manage a subscription manually. It does not have mutable boolean flags that can get out of sync with each other. It does not have a loading = true that needs to be set to false in two different places.
The entire lifecycle — subscribe, receive data, handle errors, unsubscribe — is declared as a pipeline, and Angular’s async pipe executes that pipeline. What is left in the component is the description of what should happen, not the instructions for making it happen.
The difference between imperative and declarative RxJS is the difference between a component that requires forty lines of lifecycle management and one that requires eight lines of pipeline definition. Multiply that across a large application and the maintainability difference is significant.
CSS — Rules vs Overrides
CSS has its own version of this distinction, and it is one that many developers never fully appreciate because CSS is often learned and written imperatively even though it is designed to be used declaratively.
Imperative CSS is CSS that specifies exact visual states for every situation:
/* Imperative — manually specify every variant */
.button {
background: #e87722;
color: white;
padding: 8px 16px;
}
.button.large {
padding: 12px 24px;
font-size: 18px;
}
.button.small {
padding: 4px 8px;
font-size: 12px;
}
.button.secondary {
background: white;
color: #e87722;
border: 1px solid #e87722;
}
.button.secondary.large {
padding: 12px 24px;
font-size: 18px;
} /* duplicating */
.button.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button.loading {
opacity: 0.7;
cursor: wait;
}
/* ...grows without bound */
Declarative CSS uses the cascade, custom properties, and logical structure to describe relationships:
/* Declarative — describe the system, let CSS compose it */
:root {
--btn-padding-block: 0.5rem;
--btn-padding-inline: 1rem;
--btn-font-size: 1rem;
--btn-color-bg: var(--color-primary);
--btn-color-text: white;
}
.button {
/* These values come from the token layer —
change the token, change every button */
padding: var(--btn-padding-block) var(--btn-padding-inline);
font-size: var(--btn-font-size);
background: var(--btn-color-bg);
color: var(--btn-color-text);
}
/* Size variants override only the tokens, not the properties */
.button[data-size='large'] {
--btn-padding-block: 0.75rem;
--btn-padding-inline: 1.5rem;
--btn-font-size: 1.125rem;
}
/* Style variants override only the relevant tokens */
.button[data-variant='secondary'] {
--btn-color-bg: transparent;
--btn-color-text: var(--color-primary);
border: 1px solid var(--color-primary);
}
The declarative version describes the relationship between the token layer and the component’s appearance. You declare that a button’s background is var(--btn-color-bg), and then you declare what --btn-color-bg is in each context. The properties are stated once. The variations are declared as adjustments to the variable layer, not as overrides of the properties themselves.
This is the system I built for ine of my projects. At scale — ten developers, dozens of components, multiple themes — the difference between a system that composes and a system that overrides is the difference between a codebase that stays manageable and one that becomes a specificity war.
What It Does to a Project Over Time
The individual code examples above are useful for understanding the distinction. But the real argument for declarative code is not the individual example — it is what the pattern does to a project as it grows.
Readability compounds
A project written declaratively becomes more readable over time as the vocabulary is established. Once a team understands the array method vocabulary, the RxJS operator set, the component template language, each new feature written in that vocabulary is immediately legible. The reader does not need to trace through the execution steps — they can read the intent directly.
A project written imperatively becomes harder to read over time. Each new feature adds more control flow, more mutable variables, more step-by-step machinery that needs to be traced. The cognitive load of reading the codebase increases with its size in a way that declarative codebases do not to the same degree.
If we start looking at legacy projects in our organizations, at github, or some other places, the code will almost always be entirely imperative — manual DOM queries, explicit state mutations, subscribe-then-mutate patterns throughout. Every new developer who joined the team spent weeks just tracing through the code to understand what it was doing. The migration to a more declarative pattern — proper reactive state, async pipes, template-driven class bindings — produced code that new developers could orient in hours rather than weeks.
Bugs become structural rather than logical
This distinction deserves more attention than it usually gets.
Imperative code creates opportunities for logical bugs — mistakes in the sequence of steps, forgotten state updates, inconsistent handling of edge cases. These bugs are hard to find because they require you to trace through execution paths and find the one where the steps went wrong.
Declarative code, when used correctly, eliminates categories of logical bugs by construction. If your component state is always derived from a single source of truth through a pure transformation, you cannot have a bug where the loading flag is true but data is also present. If your CSS is token-based, you cannot have a bug where one button variant’s font size is different from another because you forgot to update it. The structure of the code prevents the error rather than relying on the programmer not to make it.
This is a profound shift in how you think about reliability. Imperative code is correct when the programmer follows all the steps correctly. Declarative code is correct when the relationships are correctly described — and relationships tend to be simpler to verify than step sequences.
Testing becomes dramatically simpler
Pure functions that take state and return state are trivially testable. You pass in an input, you check the output. There is no setup, no mocking, no concern about side effects.
// Testing a declarative reducer — simple and complete
describe('ticketReducer', () => {
it('resolves a ticket', () => {
const initial: Ticket[] = [
{ id: '1', status: 'open', subject: 'Login broken' },
{ id: '2', status: 'open', subject: 'Payment failing' },
];
const result = ticketReducer(initial, { type: 'RESOLVE', id: '1' });
expect(result[0].status).toBe('resolved');
expect(result[1].status).toBe('open'); // unchanged
expect(initial[0].status).toBe('open'); // original not mutated
});
});
Compare this to testing the imperative TicketService version — you need to instantiate the service, set up its initial state, call the method, check that the internal state mutated correctly, and verify that the notification was sent. The test is longer, more fragile, and more coupled to the implementation details.
On every project where I have pushed toward more declarative patterns, the test coverage improved — not just in quantity, but in quality. The tests became smaller, faster, and more focused on behaviour rather than implementation. That is a direct consequence of the shift away from imperative mutation.
The Hardest Part of the Transition
I want to be honest about the difficulty, because I think writing that only describes the benefits of declarative code is doing a disservice to the developers who are early in the transition and finding it hard.
Declarative code requires vocabulary. You have to know what filter, map, and reduce do before you can read code written with them. You have to understand what an Observable is before RxJS makes sense. You have to understand the token layer before CSS custom properties feel like power rather than indirection.
The vocabulary acquisition takes time, and during that time, imperative code genuinely is more readable to the developer who is learning. A for loop is immediately legible to anyone who has spent a few hours with a programming language. A chained pipeline of array methods is not — not until you have used each method enough times that it becomes automatic.
This creates a real tension on teams with mixed experience levels. Code written declaratively by a senior developer can be opaque to a junior developer who has not yet absorbed the vocabulary. Code written imperatively by the same senior developer would be more immediately readable, even if it is structurally worse.
I have made mistakes in both directions on this. I have written RxJS pipelines that were elegant from my perspective and incomprehensible to the junior developers who had to maintain them. I have also written unnecessarily imperative code in the name of accessibility and then watched it become unmaintainable as the project grew.
The right answer is not to always write declaratively or to always write imperatively. It is to be deliberate — to understand what you are trading and for whom. If the codebase has a team with widely varying experience levels, a declarative approach accompanied by strong documentation and code review that explains the intent is better than imperative code that is immediately readable but does not scale. If you are working alone on a quick prototype, clarity of execution may matter more than elegance of expression.
What is never right is imperative code by default — because it is familiar, because it requires less thought, because it is how you learned. That default should be questioned deliberately, not just maintained out of habit.
A Note on Over-Declarativeness
Because I want to be fair: declarative code can become its own trap.
There is a version of declarative programming that becomes so abstracted, so layered with higher-order functions and composed operators and derived state, that it is no longer legible to anyone except the person who wrote it. RxJS chains with fifteen operators. CSS systems so deeply tokenised that changing a colour requires tracing through four levels of variable inheritance. Redux architectures so strict that a simple UI interaction requires a dozen files to change.
This is not good declarative programming. It is declarativity used as a performance of sophistication rather than a tool for clarity. The goal is always to make the code express intent more directly. When the declarative abstraction has made the intent less clear than the imperative alternative would have been, you have overshot.
The test I apply: can someone who understands the vocabulary read this code and understand what it does without needing to run it? If yes, it is probably at the right level of abstraction. If they need to set a breakpoint and trace through the execution to understand it — even if it is written in a declarative style — it has become its own kind of imperative, just harder to read.
Declarative code earns its vocabulary cost when it makes the intent clearer. When it makes the intent murkier, it has become an obstacle, not a tool.
What It Changes About How You Think
The most significant consequence of deeply internalising the declarative style is not the code you write. It is the way you think about problems before you write any code.
Imperative thinking asks: what steps do I need to take to produce this result?
Declarative thinking asks: what is the relationship between the input and the output? What rule, if stated clearly, would produce the right result automatically?
The second question is harder. It requires more upfront thought. You have to understand the problem at a level of generality that imperative thinking doesn’t require — because imperative thinking can proceed step by step without ever needing to grasp the whole shape of the solution.
But the second question produces better solutions when it works. Solutions that handle edge cases you hadn’t thought of, because the rule covers them. Solutions that are easier to extend, because adding a new case means adding a new rule, not finding the right place in a sequence to insert a new step. Solutions that are easier to reason about, because the relationship between input and output is stated directly rather than implied by a sequence of mutations.
I notice this shift most clearly in code review. When I review imperative code, I am tracing through execution paths, checking that each step is correct, looking for the case that wasn’t handled. When I review declarative code, I am checking that the relationships are correctly stated — which is a fundamentally different, and usually faster, kind of verification.
It is the difference between checking a proof step by step and checking whether the premises support the conclusion. Both matter. But one scales better with complexity.
Conclusion
The shift from imperative to declarative is not a one-time event. It is a gradual reorientation that happens differently in each domain you work in — in JavaScript, then in CSS, then in template languages, then in state management, then in reactive programming — and each iteration deepens the understanding of what you are actually doing when you describe what you want rather than how to get it.
The payoff, when it accumulates, is significant. Codebases that describe intent rather than mechanism are easier to read, easier to test, easier to extend, and more reliably correct. They communicate to the next developer what the code is trying to do, not just what it happens to be doing. They express the model of the problem rather than the execution of a solution.
None of this is free. The vocabulary has to be acquired. The team has to grow into it together. The right level of abstraction has to be found for each context. The transition is uncomfortable in the middle.
But the codebases I am most proud of having worked on — the ones that felt good to work in, that new developers could understand and contribute to quickly, that held up under the pressure of changing requirements — were the ones where the declarative style had taken hold. Not as a rule someone had written down and enforced, but as a habit of thought that the team had collectively developed.
That habit of thought is what I am still developing. It is what I mean when I say that understanding the distinction between imperative and declarative is not a milestone but a practice.
Start asking what the relationship is. Not what the steps are. The code will follow.