React MVVM Architecture with TanStack Router: Data Layer
Olexandr

Olexandr @techwood

About: 👨‍💻 Software engineer | 👨‍🏫 Mentor | 💻 Founder (not successful 🙃) | 🦾 Gym rat | 📚 Bookworm

Joined:
Aug 17, 2025

React MVVM Architecture with TanStack Router: Data Layer

Publish Date: Aug 21
1 0

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.

Diagram showing the Data Layer in MVVM architecture. The Data Layer (Repositories) connects to three sources: API (cloud), Browser Storage (cylinder), and File (document icon).

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);
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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*
Enter fullscreen mode Exit fullscreen mode

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.

Diagram of three consumers connecting to repositories, which call a shared APIService using Axios for requests.

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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),
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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*
Enter fullscreen mode Exit fullscreen mode

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

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([]);
});
Enter fullscreen mode Exit fullscreen mode

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([]);
  });
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:


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!

🔗 Follow me on LinkedIn

📰 Read me on the platform of your choice: Substack, DEV.to

Till next time 👋

Comments 0 total

    Add comment