The Single Responsibility Principle (SRP) is the “S” in SOLID and it’s beautifully simple:
Do one thing, and do it well.
In frontend apps, SRP means separating rendering, data fetching, state orchestration, and side-effects.
Keep UI components presentational, move logic into hooks/services, and isolate side-effects, and let services handle I/O.
The payoff: easier testing, fewer merge conflicts, simpler reuse, higher maintainability.
The smell: a bloated component
Let’s start with a realistic “before” example. It renders UI, fetches data, polls, handles errors, and updates the document title—all in one file.
// UserProfile.tsx (BEFORE)
// ❌ Violates SRP: rendering + async fetching + polling + side-effects all here.
import React, { useEffect, useState } from "react";
type User = { id: string; name: string; bio?: string };
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "success">("idle");
const [error, setError] = useState<string | null>(null);
// data fetching + polling
useEffect(() => {
let cancelled = false;
let interval: number | undefined;
async function fetchUser() {
try {
setStatus("loading");
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: User = await res.json();
if (!cancelled) {
setUser(data);
setStatus("success");
}
} catch (e: any) {
if (!cancelled) {
setError(e.message);
setStatus("error");
}
}
}
fetchUser();
// poll every 30s
interval = window.setInterval(fetchUser, 30_000);
return () => {
cancelled = true;
if (interval) window.clearInterval(interval);
};
}, [userId]);
// side-effect: set document title
useEffect(() => {
document.title = user ? `${user.name} | Profile` : "Loading…";
}, [user]);
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p>Could not load user: {error}</p>;
return (
<section>
<h2>{user?.name}</h2>
<p>{user?.bio ?? "No bio yet."}</p>
<button onClick={() => alert("Followed!")}>Follow</button>
</section>
);
}
Problems:
- Hard to test: you need to stub
fetch
, timers, and assert UI—all at once. - Hard to reuse: the polling rules are tied to this component.
- Hard to change: any tweak risks merge conflicts because everything lives here.
- Hard to understand: you need to "load up" a fairly involved mental model to understand and modify it with confidence.
SRP-minded refactor: split responsibilities
We’ll split into:
- A service that knows how to talk to the API (pure I/O function).
- A hook that orchestrates data fetching and polling.
- A presentational component that renders UI from data.
- A tiny side-effect hook to own the
document.title
concern.
1) Service: pure data access
// services/userService.ts
export type User = { id: string; name: string; bio?: string };
export async function fetchUser(id: string, signal?: AbortSignal): Promise<User> {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
- Single job: call the API and return data (or throw).
- Easy to mock: swap with a test double in unit tests.
2) Hook: state + orchestration (no rendering)
// hooks/useUser.ts
import { useEffect, useMemo, useRef, useState } from "react";
import { fetchUser, User } from "../services/userService";
type Status = "idle" | "loading" | "error" | "success";
export function useUser(userId: string, { pollMs = 30_000 } = {}) {
const [data, setData] = useState<User | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
async function run() {
setStatus("loading");
setError(null);
try {
const user = await fetchUser(userId, controller.signal);
if (!cancelled) {
setData(user);
setStatus("success");
}
} catch (e: any) {
if (!cancelled && e.name !== "AbortError") {
setError(e.message);
setStatus("error");
}
}
}
run();
const id = window.setInterval(run, pollMs);
return () => {
cancelled = true;
controller.abort();
window.clearInterval(id);
};
}, [userId, pollMs]);
return useMemo(() => ({ data, status, error }), [data, status, error]);
}
- Single job: own data fetching lifecycle.
- UI-agnostic: can be reused across components or pages.
3) Side-effect hook: document.title
// hooks/useDocumentTitle.ts
import { useEffect } from "react";
export function useDocumentTitle(title: string) {
useEffect(() => {
const prev = document.title;
document.title = title;
return () => {
document.title = prev;
};
}, [title]);
}
- One job: keep the browser tab title in sync.
- Easily tested or ignored in tests.
4) Presentational component: render-only
// components/UserProfileView.tsx
import React from "react";
import type { User } from "../services/userService";
type Props = {
user: User | null;
status: "idle" | "loading" | "error" | "success";
error?: string | null;
onFollow?: () => void;
};
export function UserProfileView({ user, status, error, onFollow }: Props) {
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p>Could not load user: {error}</p>;
if (!user) return null;
return (
<section>
<h2>{user.name}</h2>
<p>{user.bio ?? "No bio yet."}</p>
<button onClick={onFollow}>Follow</button>
</section>
);
}
- Single job: render prop-based state.
- Dead-simple to snapshot test or storybook.
Glue it together
// UserProfile.tsx (AFTER)
import React from "react";
import { useUser } from "./hooks/useUser";
import { useDocumentTitle } from "./hooks/useDocumentTitle";
import { UserProfileView } from "./components/UserProfileView";
export function UserProfile({ userId }: { userId: string }) {
const { data, status, error } = useUser(userId, { pollMs: 30_000 });
useDocumentTitle(status === "success" && data ? `${data.name} | Profile` : "Loading…");
return (
<UserProfileView
user={data}
status={status}
error={error}
onFollow={() => alert("Followed!")}
/>
);
}
Now each unit has one clear responsibility:
-
userService.ts
: remote I/O -
useUser
: state + lifecycle -
useDocumentTitle
: page chrome side-effect -
UserProfileView
: rendering
Testing gets easier (and faster)
With SRP, you can test each part in isolation.
Test the service (integration-ish, with MSW or a real test server)
// services/userService.test.ts
import { fetchUser } from "./userService";
// with MSW, define a handler for GET /api/users/:id
test("returns user when 200 OK", async () => {
const user = await fetchUser("123");
expect(user.id).toBe("123");
});
test("throws on non-OK", async () => {
await expect(fetchUser("404")).rejects.toThrow(/HTTP 404/);
});
Test the hook (unit: mock the service)
// hooks/useUser.test.tsx
import { renderHook, waitFor } from "@testing-library/react";
import * as service from "../services/userService";
import { useUser } from "./useUser";
jest.spyOn(service, "fetchUser");
test("loads user and sets status", async () => {
(service.fetchUser as jest.Mock).mockResolvedValue({ id: "123", name: "Ada" });
const { result } = renderHook(() => useUser("123", { pollMs: 60_000 }));
expect(result.current.status).toBe("loading");
await waitFor(() => expect(result.current.status).toBe("success"));
expect(result.current.data?.name).toBe("Ada");
});
Test the view (pure rendering)
// components/UserProfileView.test.tsx
import { render, screen } from "@testing-library/react";
import { UserProfileView } from "./UserProfileView";
test("shows name when success", () => {
render(<UserProfileView user={{ id: "1", name: "Ada" }} status="success" />);
expect(screen.getByText("Ada")).toBeInTheDocument();
});
No global fetch mocks in UI tests, no timers to juggle, no document title assertions unless you’re testing the small hook. Lower friction, faster feedback.
Tools you might use:
- React Testing Library
- MSW for API mocking during tests and local dev
- Jest or Vitest
SRP heuristics for React
- Name your units after their job. If you can’t describe it in one short phrase, it probably has more than one responsibility.
- UI components render. If they start performing I/O, timers, or global side-effects, consider extracting a hook or a service.
- Hooks orchestrate state. They don’t render, and they should avoid DOM mutations outside React (leave that to the browser or small effect hooks).
- Services talk to the outside world. Keep them small and pure (params in, data/error out).
-
Prefer composition over conditionals. Multiple responsibilities often appear as long
if/else
blocks; split them into smaller components or hooks. - Test the smallest stable seam. Views via RTL, hooks via renderHook, services via MSW or direct mocks.
Common objections (and responses)
“But I’ll have more files.”
True—and each one is shorter, easier to read, and easier to diff. Merge conflicts go down because teams touch different files.“Isn’t this premature abstraction?”
SRP isn’t abstraction for abstraction’s sake. It’s a guideline to keep responsibilities cohesive. Start simple; extract when a concern grows.“Performance?”
Splitting into components/hooks doesn’t inherently hurt performance. In fact, clearer boundaries make memoization and virtualization easier.
Wrap-up
Single responsibility principle in React isn’t academic. It’s a practical way to reduce coupling, shrink tests, increase reuse and increase maintainability. Keep UI presentational, push logic into hooks, isolate side-effects, and let services handle I/O.
Do one thing, do it well.
Top comments (0)