When developing your frontend application, most of the time you work with the data coming from or sending it somewhere. And as discussed in this five-layer MVVM implementation, you have to use the repository for these purposes. It sits just above the network or browser APIs, providing a clean, testable interface to the rest of your app.
TIP: If you haven’t read my MVVM Introduction yet, I highly recommend doing that first. It’ll help you understand where the data layer fits in the bigger picture.
In this article, you’ll learn:
- What a repository is and when to use it.
- Practical implementation with the source code.
- How to write unit tests for the repository.
By the end, you’ll know how to build a robust data layer that lets you scale your app without accumulating technical debt.
What is a Repository?
A repository is a common design pattern that allows you to encapsulate data-accessing logic. Some of the tasks for the repository may include:
- Communicate with the external storage.
- Define a type for the raw response structure (e.g., JSON) returned from the external storage.
- Handle storage-related errors, so the application layer can conveniently deal with them later.
- Cache the data or delegate caching responsibility to third-party libraries.
When to use Repository?
- When your app has to talk to two or more external endpoints.
- When you want to isolate the data accessing logic from UI or business logic.
- When there’s a breaking change in a third-party API, the repository is the only place where you’ll need to make changes.
Let’s roll up our sleeves 🛠️
To make this article and all the next ones in the series more engaging, we’ll develop a country flags quiz app. It will prompt the user with the flag, and they will have to guess the country. Then the system saves the answer to the localStorage and displays the answer history in the left sidebar. You can find the source code in this GitHub repository and a Demo on CodeSandbox.
Bootstrap a new TanStack Router app using the package manager of your choice. Next, remove all the redundant files (in our case: reportWebVitals.ts
, logo.svg
, and so on) and you’re good to go.
Your first repository
Okay, I’m not sure it’s your first-ever repository, but at least it is in this architecture series:
import { apiServiceFactory, type APIService } from "@/shared/api";
/**
* The repository for the Home module of the app.
*/
export class HomeRepository {
/**
* Classes that we inject into the repository through the constructor.
*/
private readonly apiService: APIService;
constructor(apiService: APIService) {
this.apiService = apiService;
}
/**
* Repository's method to find all avaliable countries.
*
* Notice that we didn't specify a proper type for the result
* of `get` method (used generic `any` instead).
* We'll do it later in the model layer article.
*/
findCountries() {
return this.apiService.get<any[]>("/all?fields=name,flags");
}
}
/**
* Factory method to spawn a new instance of HomeRepository and avoid singletone
*
* It also injects `APIService` into the `HomeRepository`.
* Keep reading to get what `APIService` is.
*/
export const homeRepositoryFactory = () => {
const apiService = apiServiceFactory(["Home"]);
return new HomeRepository(apiService);
};
I know, a lot of code right off the bat, but in a nutshell, the repository is usually a class with a couple of private fields, a constructor, and methods. This repository is called HomeRepository
because it’s a part of the Home
module where the quiz happens.
NOTE: There’s no strict rule saying a repository has to be a class. I’ll explain that in the article down below.
At the moment, your folder structure within the src
directory should look like this:
src/
├── routes/
│ ├── __root.tsx
│ └── index.tsx
└── modules/
└── Home/
└── data/
└── home.repository.ts
Now let’s focus on interesting tidbits 👇
Make all repositories dumb
Wait what? Don’t we, as smart developers, write smart programs for smart people? Yes, we do. But here’s the paradox: we need to make the individual parts of our programs dumb and abstract, so the whole system is intelligent. Our repository is not an exception!
A repository should never know which library you’re using: axios
, fetch
, or another-shiny-library
. Right now, we have one repository, and things are simple. But imagine our country quiz app blew up - we raised billions, devs are no longer hungry, and they added 100+ repositories. Each one uses axios
directly. One day, the CTO comes and says: “NewHypingHttpLibrary
just came up, it’s 100x faster and more secure, we need to migrate ASAP”. Damn, now you are in “you vs 100-repositories-to-be-refactored-tomorrow” position.
To avoid such situations, we introduce a special class called APIService
:
import { queryOptions } from "@tanstack/react-query";
import axios, { type AxiosInstance } from "axios";
/**
* A facade over HTTP library to perform API requests.
*/
export class APIService {
/**
* The Axios client instance used to perform HTTP requests.
*/
private readonly apiClient: AxiosInstance;
/**
* Query key provided by the consumer of the `APIService` class.
*/
private readonly queryKey: string[];
constructor(apiClient: AxiosInstance, queryKey: string[]) {
this.apiClient = apiClient;
this.queryKey = queryKey;
}
/**
* Executes a GET request to the provided API endpoint
*
* @template D - The expected response data type.
*
* @param endpoint - The path to the API endpoint.
*
* @returns Query options object for the use with TanStack Query.
*/
get<D>(endpoint: string) {
return queryOptions({
queryKey: this.queryKey,
queryFn: () => this.apiClient.get<D>(endpoint),
});
}
}
/**
* Factory function to create a configured instance of the `APIService` class.
*
* @param queryKey - The query key array used for caching and identifying queries.
*
* @returns A configured `APIService` instance ready for making API requests.
*/
export const apiServiceFactory = (queryKey: string[]) => {
const apiClient = axios.create({
baseURL: "https://restcountries.com/v3.1",
adapter: "fetch",
});
return new APIService(apiClient, queryKey);
};
You can put api.service.ts
wherever you want, but I’ll put it in src/shared/api
to indicate that this is an API-related piece of code shared across the modules:
src/
├── modules/
│ └── Home/
│ └── data/
│ └── home.repository.ts
├── routes/
│ ├── __root.tsx
│ └── index.tsx
├── shared/
│ └── api/
│ ├── api.service.ts
│ └── index.ts *- index file for re-exporting*
Now HomeRepository
and other repositories depend on the abstract APIService
interface, not a concrete implementation. That way, you can easily replace the underlying HTTP library (like axios or fetch) or make other internal changes.
NOTE: This approach satisfies the Dependency Inversion principle of SOLID. Find out more about SOLID in TypeScript.
Querying on steroids
In the get
method of APIService
, you may notice that we return the result of the queryOptions
function:
get<D>(endpoint: string) {
return queryOptions({
queryKey: this.queryKey,
queryFn: () => this.apiClient.get<D>(endpoint),
});
}
That’s because I propose using TanStack Query for making HTTP requests in our app. It will handle all the errors and cache the data for us, so we don’t have to write and test this logic ourselves. You can find all the benefits the library provides in the link I’ve put in the previous sentence.
NOTE: I’ll rely on TanStack Query in this article and all the next ones, though you’re free to use another data-fetching library, or not to use any.
A repository doesn’t always have to be a class
As mentioned before, a repository is an interface to communicate with the external data source. And there’s no strict rule that it has to be a class. Basically, it can be anything: a function, a class, or even JavaScript module exposing pure methods. As long as it encapsulates access to the external data, it’s fine.
For example, we agreed that we’ll save the history in localStorage. To make it easy to work with this browser API, we can use Zustand and its persist
and createJSONStorage
functions.
NOTE: In this article, I won’t explain how to set up Zustand. You can read the official documentation or watch this Zustand video tutorial by Cosden Solutions. You can also find the configured store on GitHub. Pay attention to where I put it!
Let’s create a historySlice
that will be a part of the History
module:
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export type HistorySlice = {
history: any[];
saveAnswer: (result: any) => void;
clearHistory: () => void;
};
export const historySlice = create<HistorySlice>()(
persist(
(set) => ({
history: [],
// Saves the user's answer to the localStorage
saveAnswer: (answer) =>
set((state) => ({ history: [answer, ...state.history] })),
// Clears the history from the localStorage
clearHistory: () => set({ history: [] }),
}),
{
// localStorage key
name: "history",
// Tells Zustand to use localStorage as a persistent storage for our data
storage: createJSONStorage(() => localStorage),
}
)
);
This is another example of an elegant repository:
- We avoid calling localStorage directly, utilizing the API provided by the repository.
- Each slice is a different function that we can independently develop and test.
- Zustand enables reactive updates, which reduces the need for manual data synchronization between the localStorage consumers.
Now you can either use historySlice
directly in your code or wrap it in HistoryRepository
, which I recommend:
import { historySlice } from "./history.slice";
/**
* Repository for managing history in browser storage.
*/
export class HistoryRepository {
/**
* Slice used to access the data.
*/
private readonly slice: typeof historySlice;
constructor(slice: typeof historySlice) {
this.slice = slice;
}
/**
* Saves a new result to the beginning of the history array.
*
* Append `createdAt` timestamp to indicate when the record was created
*
* @param createHistoryRecordDto - The new result to save
*/
saveAnswer(createHistoryRecordDto: any) {
return this.slice
.getState()
.saveAnswer({ ...createHistoryRecordDto, createdAt: Date.now() });
}
/**
* Returns an array of all saved history records.
* If no history exists, returns an empty array.
*
* @returns Array of history records.
*/
getHistory() {
return this.slice((state) => state.history);
}
}
/**
* Factory function to create a HistoryRepository instance.
*
* @returns A new HistoryRepository instance
*/
export const historyRepositoryFactory = () =>
new HistoryRepository(historySlice);
Keep in mind that it’s an optional step, and you can skip creating an additional class if you feel like it’s overkill for you.
Still two main reasons to do it:
- Any consumer of the data layer will always know - the repository class is always the main entry point into the layer. They won’t need to care whether it’s Zustand, localStorage, or anything else under the hood.
- Even better DI - you can easily replace Zustand with another state management library if you ever need it.
At this point, your modules
folder structure should look like this:
src/
└── modules/
├── Home/
│ └── data/
│ └── home.repository.ts
└── History/
└── data/
├── history.repository.ts
├── history.slice.ts
└── index.ts *- index file for re-exporting*
And that wraps up the repository implementation. Now let’s make sure everything behaves correctly by writing some tests 🧪.
Testing
NOTE: As a testing framework I’ll use Vitest.
First, let’s write unit tests for HomeRepository
:
import type { APIService } from "@/shared/api/api.service";
import { describe, expect, it, vi } from "vitest";
import { HomeRepository, homeRepositoryFactory } from "../home.repository";
// Mock APIService
function createMockApiService(getImpl?: any): APIService {
return {
get: getImpl || vi.fn(),
} as unknown as APIService;
}
describe("HomeRepository", () => {
let apiService: APIService;
describe("findCountries", () => {
it("should return countries", async () => {
const mockCountries = [
{
name: {
common: "Ukraine",
official: "Ukraine",
nativeName: {
ukr: { official: "Україна", common: "Україна" },
},
},
flags: {
png: "ua.png",
svg: "ua.svg",
alt: "Flag of Ukraine",
},
},
{
name: {
common: "Poland",
official: "Republic of Poland",
nativeName: {
pol: { official: "Rzeczpospolita Polska", common: "Polska" },
},
},
flags: {
png: "pl.png",
svg: "pl.svg",
alt: "Flag of Poland",
},
},
];
apiService = createMockApiService(
vi.fn().mockResolvedValue(mockCountries)
);
// Easily inject any APIService into HomeRepository
const repo = new HomeRepository(apiService);
const result = await repo.findCountries();
expect(result).toEqual(mockCountries);
});
it("should return empty array", async () => {
apiService = createMockApiService(vi.fn().mockResolvedValue([]));
const repo = new HomeRepository(apiService);
const result = await repo.findCountries();
expect(result).toEqual([]);
});
it("should fail when API throws", async () => {
apiService = createMockApiService(
vi.fn().mockRejectedValue(new Error("API fail"))
);
const repo = new HomeRepository(apiService);
await expect(repo.findCountries()).rejects.toThrow("API fail");
});
});
});
describe("homeRepositoryFactory", () => {
it("should return HomeRepository instance with working apiService", () => {
const repo = homeRepositoryFactory();
expect(repo).toBeDefined();
expect(typeof repo.findCountries).toBe("function");
});
});
Notice how straightforward it is to test the repository since we made it “dumb” earlier:
it("should return empty array", async () => {
apiService = createMockApiService(vi.fn().mockResolvedValue([]));
const repo = new HomeRepository(apiService);
const result = await repo.findCountries();
expect(result).toEqual([]);
});
We can inject any specimen of the APIService
into the HomeRepository
. This shows Dependency Inversion in action. The repository just needs an object that fits the APIService
interface, which makes mocking and testing a piece of cake.
Here’s how we can test historySlice
:
import { beforeEach, describe, expect, it } from "vitest";
import { historySlice } from "../history.slice";
// Mock localStorage for Zustand persist
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
describe("historySlice", () => {
let slice: typeof historySlice;
const sample = {
flagImage: "flag.png",
countryName: "Ukraine",
userAnswer: "Ukraine",
};
// Start from scratch each test case - clear the history
beforeEach(() => {
window.localStorage.clear();
slice = historySlice;
slice.getState().clearHistory();
});
it("should initialize with empty history", () => {
expect(slice.getState().history).toEqual([]);
});
it("should save an answer to history", () => {
slice.getState().saveAnswer(sample);
expect(slice.getState().history[0]).toEqual(sample);
});
it("should save multiple answers in correct order", () => {
const answer = {
flagImage: "b.png",
countryName: "Poland",
userAnswer: "Poland",
};
// sample is inserted first, but should appear last in the final array
slice.getState().saveAnswer(sample);
// answer is inserted last, but should appear first in the final array
slice.getState().saveAnswer(answer);
expect(slice.getState().history[0]).toEqual(answer);
// sample is second, because Zustand append new value at the start of the array
expect(slice.getState().history[1]).toEqual(sample);
});
it("should clear history", () => {
slice.getState().saveAnswer(sample);
slice.getState().clearHistory();
expect(slice.getState().history).toEqual([]);
});
});
Because Zustand persists in the localStorage, we need to mock it in test environments that lack browsers’ API:
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
As you can see, test cases use regular Zustand syntax to assert the expected results:
it("should save an answer to history", () => {
slice.getState().saveAnswer(sample);
expect(slice.getState().history[0]).toEqual(sample);
});
As a little practice task, try to write unit tests for HistoryRepository
on your own. Find my implementation of tests for the history repository on GitHub.
Summary
You made it! 🔥
Now you know:
- What a repository is and how to use it.
- Why the dependency inversion principle is important and how the repository allows you to fulfill it.
Additionally, you’ve learned how to utilize modern libraries like TanStack Query and Zustand to make your repositories more powerful than ever.
As icing on the cake, we discussed how to test different kinds of repositories. You saw how effortless it is to write unit tests when the repo follows clean architecture*.*
What’s next?
Deep dive into the series to grasp the whole picture about modern MVVM architecture in React:
- React MVVM Architecture with TanStack Router: Model Layer
- React MVVM Architecture with TanStack Router: View Layer
- React MVVM Architecture with TanStack Router: Application Layer
- React MVVM Architecture with TanStack Router: Orchestrator
Creating content like this is not an easy ride, so I hope you’ll hit that like button and drop a comment. As usual, constructive feedback is always welcome!
📰 Read me on the platform of your choice: Substack, DEV.to
Till next time 👋