Command Query Separation Meets React Server Components: A Pragmatic Guide
Introduction
React Server Components (RSC) are changing the way we think about data fetching in React. By allowing components to run on the server, they eliminate the need for a separate data‑layer in the client bundle and make it possible to stream UI directly from the backend.
One architectural principle that fits naturally with RSC is Command Query Separation (CQS) – the idea that queries (operations that return data) should never have side‑effects, while commands (operations that change state) should never return data. Applying CQS to RSC helps you:
- Keep server‑side code pure and easy to reason about.
- Reduce accidental data races when multiple components request the same resource.
- Make unit‑testing of data‑access logic straightforward.
In this article we’ll walk through a real‑world example: a simple task‑management UI built with Next.js 13+ (which ships RSC out of the box). We’ll see how to structure the codebase, write reusable query and command modules, and wire them into server components without leaking implementation details to the client.
1. The Core Idea of CQS in a React Context
| CQS Concept | Typical Implementation | RSC‑Friendly Mapping |
|---|---|---|
| Query | GET /api/tasks → returns JSON |
Server component imports a query function that returns a serializable value. |
| Command | POST /api/tasks → creates a task, returns 201 |
Server component (or an action) imports a command function that performs the mutation and returns void or a simple status flag. |
The key is separation of concerns: a component that only displays data should never be responsible for mutating it, and vice‑versa. In RSC this separation is enforced by the runtime – server components can only import server‑only modules, while client components can import both but must treat commands as async actions (e.g., via useFormAction or fetch with method: "POST").
2. Project Layout
/app
/tasks
page.server.tsx // RSC that renders the task list
add-task.form.tsx // Client component with a form
/lib
/queries
getTasks.ts // Query: fetches tasks from DB
/commands
createTask.ts // Command: inserts a new task
db.ts // Minimal DB wrapper (e.g., Prisma, Drizzle)
All files under /app are automatically treated as server components unless they contain "use client" at the top.
The queries and commands folders contain pure functions that never mix responsibilities.
3. Implementing the Query
// lib/queries/getTasks.ts
import { db } from '../db';
export async function getTasks() {
// Pure read – no side‑effects, no caching logic here
const rows = await db.task.findMany({
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, completed: true },
});
// Return a plain serializable object
return rows;
}
Why this is a query:
- It only reads from the database.
- It returns data that can be safely serialized and streamed to the client.
- No mutation, no logging that could affect the result.
4. Implementing the Command
// lib/commands/createTask.ts
import { db } from '../db';
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
});
export async function createTask(input: unknown): Promise<void> {
// Validation is part of the command – it guarantees invariants before mutation
const { title } = CreateTaskSchema.parse(input);
await db.task.create({
data: {
title,
completed: false,
},
});
}
Why this is a command:
- It performs a write operation (
INSERT). - It returns
void– the caller does not expect a payload, only success/failure. - Validation lives here, keeping the query side clean.
5. Consuming the Query in a Server Component
// app/tasks/page.server.tsx
import { getTasks } from '@/lib/queries/getTasks';
import AddTaskForm from './add-task.form';
export default async function TasksPage() {
const tasks = await getTasks(); // ✅ Pure query, no side‑effects
return (
<section>
<h1>My Tasks</h1>
<AddTaskForm />
<ul>
{tasks.map((t) => (
<li key={t.id}>
<input type="checkbox" defaultChecked={t.completed} disabled />
{t.title}
</li>
))}
</ul>
</section>
);
}
Because TasksPage is a server component, the await getTasks() runs on the server during rendering. The resulting HTML is streamed to the client, and the client never sees the query implementation.
6. Wiring the Command to a Client Form
// app/tasks/add-task.form.tsx
'use client';
import { useState, FormEvent } from 'react';
import { createTask } from '@/lib/commands/createTask';
export default function AddTaskForm() {
const [title, setTitle] = useState('');
const [status, setStatus] = useState<'idle' | 'pending' | 'error'>('idle');
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setStatus('pending');
try {
// The command runs on the server via the fetch API
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
setTitle('');
setStatus('idle');
// Optionally trigger a revalidation (Next.js 13+)
// mutate('/api/tasks') // using SWR or similar
} catch {
setStatus('error');
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New task"
required
/>
<button type="submit" disabled={status === 'pending'}>
Add
</button>
{status === 'error' && <p className="error">Failed to add task.</p>}
</form>
);
}
The form posts to an API route that simply forwards the request to the command:
// pages/api/tasks.ts (Next.js API route)
import { createTask } from '@/lib/commands/createTask';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end();
}
try {
await createTask(req.body);
res.status(201).end();
} catch (err) {
console.error(err);
res.status(400).json({ error: 'Invalid payload' });
}
}
Notice how the command is the only place that knows about the database. The API route is a thin transport layer, and the client component never imports any query logic.
7. Keeping the UI Fresh – Revalidation Strategies
CQS does not prescribe a particular caching strategy, but it works nicely with stale‑while‑revalidate patterns:
- Server‑side revalidation – In Next.js 13 you can call
revalidatePath('/tasks')inside the command after a successful mutation. This tells the framework to invalidate the cached result ofgetTaskson the next request. - Client‑side SWR – If you need instant UI feedback, you can fetch the task list with a client hook (
useSWR('/api/tasks')) that internally calls the same query endpoint. The command remains unchanged; only the consumer decides whether to use server‑side streaming or client‑side fetching.
// lib/queries/getTasksApi.ts
export async function getTasksApi() {
const res = await fetch('/api/tasks');
if (!res.ok) throw new Error('Failed to load tasks');
return res.json();
}
The separation ensures that the query can be used in both environments without duplication.
8. Testing the CQS Layers
Because queries and commands are pure functions (aside from the DB driver), they can be unit‑tested in isolation.
// __tests__/queries/getTasks.test.ts
import { getTasks } from '@/lib/queries/getTasks';
import { db } from '@/lib/db';
jest.mock('@/lib/db');
test('returns ordered tasks', async () => {
(db.task.findMany as jest.Mock).mockResolvedValue([
{ id: 2, title: 'B', completed: false },
{ id: 1, title: 'A', completed: true },
]);
const tasks = await getTasks();
expect(tasks).toHaveLength(2);
expect(tasks[0].title).toBe('B'); // ordered by createdAt desc
});
Commands are tested similarly, asserting that the correct DB method is called and that validation errors surface as expected.
9. Benefits Recap
| Benefit | How CQS + RSC Deliver It |
|---|---|
| Predictable data flow | Queries are read‑only, commands never return data, eliminating hidden side‑effects. |
| Smaller bundles | Server‑only query/command code never reaches the client bundle. |
| Easier refactoring | Swapping a DB implementation only touches the query/command modules. |
| Better testability | Pure functions can be unit‑tested without a full server. |
| Streaming UI | Server components can stream query results while commands run asynchronously via API routes. |
10. Common Pitfalls & How to Avoid Them
- Mixing query and command logic – Resist the temptation to return the newly created entity from a command. Instead, let the client re‑fetch or rely on revalidation.
- Leaking server‑only imports into client components – If a client component imports a query directly, the bundler will pull server code into the client bundle, breaking the separation. Use an API route or a client‑side fetch wrapper.
- Over‑using
use client– Only mark components that truly need client interactivity. The more you keep as server components, the more you benefit from CQS. - Ignoring error handling in commands – Validation should be part of the command; surface errors through proper HTTP status codes so the client can react gracefully.
11. Extending the Pattern
- Batch Commands – For bulk updates, create a command that accepts an array of DTOs and performs a single transaction.
- Read‑Model Projections – If you need a denormalized view (e.g., tasks with comment counts), write a dedicated query that joins or aggregates data, keeping it separate from the write model.
- Event‑Sourcing – Commands can emit domain events after mutation; queries can then read from a materialized view. This keeps the CQS spirit while enabling advanced scalability.
12. Conclusion
Applying Command Query Separation to React Server Components gives you a clean, testable, and performant architecture for modern web apps. By isolating reads and writes into dedicated modules, you let the server‑rendering engine do what it does best—stream data—while keeping client code lightweight and focused on interaction.
Give it a try in your next Next.js project: start by extracting existing data‑fetching logic into queries/ and commands/, add a thin API route for mutations, and watch the bundle size shrink and the mental model sharpen.
Happy coding!
Member discussion