Repository Pattern in React: Data Abstraction
The Repository Pattern abstracts data access behind an interface, so your application never directly calls a database, API, or file system. Instead, repositories translate high-level operations (save, find, delete) into storage-specific logic. This pattern is the practical embodiment of the hexagonal architecture's data ports: swap your API for GraphQL, your HTTP client for real-time WebSocket, or your backend service for local storage without rewriting business logic. In a modern React app, repositories are adapters that sit between use cases and data sources.
The Repository Pattern is one of the most underrated patterns in frontend development. I've seen teams agonize over Zustand vs. Redux when the real problem is direct API calls scattered through components. A single repository layer often eliminates the need for complex state management altogether. When data access is abstracted, state management becomes straightforward: fetch via repository, store in state, render. No Redux middleware, no Redux Saga, just clean flow.
The Core Idea: Separation of Data Logic
Without repositories, data access is scattered:
// Components call the API directly (tight coupling)
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// HTTP logic mixed with component logic
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
.catch(err => console.error(err));
}, [userId]);
return <div>{user?.name}</div>;
}
Problems:
- Every component that fetches users has to replicate the HTTP logic.
- Changing the API endpoint requires editing multiple components.
- Error handling is inconsistent across components.
- Testing requires mocking fetch in every test.
With repositories, data logic is centralized:
// Repository: abstraction for user data
export class UserRepository {
async getById(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
async save(user) {
const response = await fetch(`/api/users`, {
method: 'POST',
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Save failed');
return response.json();
}
async delete(id) {
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Delete failed');
}
}
// Component: uses the repository
export function UserProfile({ userId, userRepository }) {
const [user, setUser] = useState(null);
useEffect(() => {
userRepository.getById(userId).then(setUser);
}, [userId, userRepository]);
return <div>{user?.name}</div>;
}
Now:
- HTTP logic is in one place; reuse across components.
- Changing the API is a one-line edit in the repository.
- Error handling is consistent.
- Tests inject a mock repository.
Repository Interface
Define a TypeScript interface (or JSDoc) so all implementations conform to the same contract:
export interface Repository<T> {
getById(id: string): Promise<T>;
getAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
export interface UserRepository extends Repository<User> {
findByEmail(email: string): Promise<User | null>;
}
Any UserRepository implementation (HTTP, GraphQL, WebSocket, local storage) must provide these methods. This contract enables swapping implementations.
Concrete Implementations
HTTP Repository (REST API):
export class UserHttpRepository {
constructor(baseURL = '/api') {
this.baseURL = baseURL;
}
async getById(id) {
const response = await fetch(`${this.baseURL}/users/${id}`);
if (!response.ok) throw new Error('Not found');
return response.json();
}
async getAll() {
const response = await fetch(`${this.baseURL}/users`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
async save(user) {
const method = user.id ? 'PUT' : 'POST';
const url = user.id ? `${this.baseURL}/users/${user.id}` : `${this.baseURL}/users`;
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Save failed');
return response.json();
}
async delete(id) {
const response = await fetch(`${this.baseURL}/users/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Delete failed');
}
async findByEmail(email) {
const response = await fetch(`${this.baseURL}/users/by-email/${email}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Search failed');
return response.json();
}
}
GraphQL Repository:
export class UserGraphQLRepository {
constructor(client) {
this.client = client;
}
async getById(id) {
const result = await this.client.query({
query: gql`
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
`,
variables: { id },
});
return result.data.user;
}
async getAll() {
const result = await this.client.query({
query: gql`
query {
users { id name email }
}
`,
});
return result.data.users;
}
async save(user) {
const result = await this.client.mutate({
mutation: gql`
mutation SaveUser($input: UserInput!) {
saveUser(input: $input) { id name email }
}
`,
variables: { input: user },
});
return result.data.saveUser;
}
async delete(id) {
await this.client.mutate({
mutation: gql`
mutation DeleteUser($id: ID!) {
deleteUser(id: $id)
}
`,
variables: { id },
});
}
async findByEmail(email) {
const result = await this.client.query({
query: gql`
query FindUser($email: String!) {
userByEmail(email: $email) { id name email }
}
`,
variables: { email },
});
return result.data.userByEmail || null;
}
}
Local Storage Repository (offline-first):
export class UserLocalStorageRepository {
constructor(storageKey = 'users') {
this.storageKey = storageKey;
}
async getById(id) {
const users = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
const user = users.find(u => u.id === id);
if (!user) throw new Error('Not found');
return user;
}
async getAll() {
return JSON.parse(localStorage.getItem(this.storageKey) || '[]');
}
async save(user) {
const users = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
if (user.id) {
const index = users.findIndex(u => u.id === user.id);
users[index] = user;
} else {
user.id = String(Date.now());
users.push(user);
}
localStorage.setItem(this.storageKey, JSON.stringify(users));
return user;
}
async delete(id) {
const users = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
const filtered = users.filter(u => u.id !== id);
localStorage.setItem(this.storageKey, JSON.stringify(filtered));
}
async findByEmail(email) {
const users = await this.getAll();
return users.find(u => u.email === email) || null;
}
}
All three implement the same interface. Swap them by changing a single line at the app root.
Using Repositories in Use Cases
Use cases depend on the repository interface, not the specific implementation:
export class RegisterUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(email, name) {
// Check if email already exists
const existing = await this.userRepository.findByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
// Create and save
const user = new User(null, email, name);
return this.userRepository.save(user);
}
}
export class GetUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(id) {
const user = await this.userRepository.getById(id);
if (!user) throw new Error('User not found');
return user;
}
}
The use case does not know if the repository is HTTP, GraphQL, or local storage. It only knows the interface.
Testing with Mock Repositories
Inject a mock repository in tests:
class MockUserRepository {
constructor() {
this.users = new Map();
}
async getById(id) {
const user = this.users.get(id);
if (!user) throw new Error('Not found');
return user;
}
async save(user) {
user.id = user.id || String(this.users.size + 1);
this.users.set(user.id, user);
return user;
}
async findByEmail(email) {
return Array.from(this.users.values()).find(u => u.email === email) || null;
}
}
describe('RegisterUserUseCase', () => {
it('saves user with unique email', async () => {
const repo = new MockUserRepository();
const useCase = new RegisterUserUseCase(repo);
const user = await useCase.execute('[email protected]', 'John');
expect(user.id).toBeDefined();
expect(user.email).toBe('[email protected]');
});
it('rejects duplicate email', async () => {
const repo = new MockUserRepository();
await repo.save(new User('1', '[email protected]', 'John'));
const useCase = new RegisterUserUseCase(repo);
await expect(
useCase.execute('[email protected]', 'Jane')
).rejects.toThrow('Email already registered');
});
});
No network mocks, no HTTP stubs. Just a simple mock repository.
Repository vs. Direct API Calls
| Aspect | Direct API Calls | Repository |
|---|---|---|
| Coupling | Component depends on specific API endpoint | Component depends on repository interface |
| Reuse | Each component duplicates HTTP logic | One repository, used by all |
| Testing | Must mock fetch globally or use msw | Mock repository locally |
| Swapping implementations | Edit multiple components | Edit one repository class |
| Error handling | Scattered across components | Centralized |
Key Takeaways
- Repositories abstract data access behind an interface.
- Implement the same repository interface for HTTP, GraphQL, local storage, or any data source.
- Use cases depend on the repository interface, not the specific implementation.
- Swap repositories by changing one line: inject a different implementation.
- Repositories eliminate the need for scattered API calls; use cases stay clean.
Frequently Asked Questions
Does a repository make state management unnecessary?
Not entirely, but it simplifies it. If you fetch via repository and store the result in local state, you avoid global state management for most cases. Use Context or Zustand for truly global state (logged-in user, theme). Repositories replace complex state management for domain data.
Should repositories handle caching?
Partially. A repository can cache reads to avoid redundant requests, but heavy caching logic belongs in a dedicated caching layer. Keep repositories simple: fetch and save. If caching is important, wrap the repository in a caching adapter.
How do repositories relate to data normalization?
Repositories can normalize data on fetch (transform API responses to domain objects) and denormalize on save (transform domain objects to API request bodies). This keeps use cases and components working with normalized, consistent domain models.
Can I use repositories with React Query or SWR?
Yes. Query libraries focus on caching and state management; repositories focus on data access. They complement each other. Use a repository for the actual fetch; wrap it in React Query for caching and synchronization.