React MVVM Architecture with TanStack Router: Model Layer
Olexandr

Olexandr @techwood

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

Joined:
Aug 17, 2025

React MVVM Architecture with TanStack Router: Model Layer

Publish Date: Aug 21
1 0

Types are playing an essential part in modern React applications. They’re the foundation for typing component props, shaping unstructured data like JSON, and defining request bodies. And that’s only the tip of the iceberg of use cases you might run into.

Meme of Yoda with caption “WITH GREAT POWER COMES GREAT RESPONSIBILITY.”

We, as developers, often focus so much on business logic, performance, security, and all the other critical aspects of our apps that we just forget about the type system. As the project evolves, types get messy - names are inconsistent, and there’s no clear place to store them. That hurts the developer experience. Teams start using any more commonly, and it leads to inconsistency between the backend and the frontend. As a result, bugs emerge.

Today we’re talking about the model layer, which is a part of the five-layer MVVM architecture

Diagram of MVVM architecture layers: Orchestrator, Data Layer (Repositories), Model layer (highlighted) (DTO & Payload, Module Type, Prop types), Application Layer (Hooks), and View Layer (Widgets).

Its goal is to tackle all of the hurdles listed above, which means fewer bugs, zero any usage (unless it’s absolutely required), and easier refactoring.

TIP: If you haven’t read my MVVM Introduction yet, I highly recommend doing that first. It’ll help you understand where the model layer fits in the bigger picture.

In this article, you’ll learn:

  • How to add types to the unstructured data you receive and send to the external sources.
  • How to structure the data so it can be conveniently displayed on the UI.
  • How to organize the prop types in the MVVM style.

Before we begin, I just want to remind, that in this MVVM series, we’re building a country quiz app. The app prompts the user with the flag, and the user has to guess the name of the country. Once they entered and sent the answer, we save it in the localStorage. Then we display the answer history in the left sidebar. You can find the source code on GitHub and the Demo on CodeSandbox.

Payload: Typing the API Response

Payloads define a structure for the data your app receives from the external data source. For example, it can be a type of GET query or WebSocket channel response.

In the article explaining the data layer, we had a method called findCountries to fetch all the countries. If you remember, we’ve put any[] for the response type instead of a valid one. Now let’s create a payload for it:

import * as z from "zod/v4";

// Define an object for a single country returned from the countries API
const countryPayload = z.object({
  flags: z.object({
    png: z.string().nonempty(),
    svg: z.string().nonempty(),
    alt: z.string(),
  }),
  name: z.object({
    common: z.string().nonempty(),
    official: z.string().nonempty(),
    nativeName: z.record(
      z.string().nonempty(),
      z.object({
        official: z.string().nonempty(),
        common: z.string().nonempty(),
      })
    ),
  }),
});

// Define an array of countries
export const countriesPayload = z.array(countryPayload);

// Define a type out of countryPayload object using Zod's infer
export type CountryPayload = z.infer<typeof countryPayload>;
Enter fullscreen mode Exit fullscreen mode

NOTE: Follow the link to get the structure for the payload

To define a payload object and a type, I use Zod. It allows us to perform runtime validation of the layer’s entities. This ensures malformed data from the third-party APIs never breaks the app. Later, when we work on the business logic, I will show you detailed validation examples.

Currently, we’re interested in the CountryPayload type. Without Zod, it would look like this:

export type CountryPayload = {
  flags: {
    png: string;
    svg: string;
    alt: string;
  };
  name: {
    common: string;
    official: string;
    nativeName: {
      [key: string]: {
        official: string;
        common: string;
      };
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

With the type created, your folder structure should look like this:

src/
├── modules/
│   └── Home/
│       └── model/
│           ├── home.dto.ts
│           └── index.ts - index file for re-exporting
Enter fullscreen mode Exit fullscreen mode

Yes, it’s fine to store payload in the DTO file.

Zod is a completely optional package, and its usage depends on your project’s needs. Like I said, I’ll show you more Zod benefits later in the application layer. Meanwhile, you can specify a return type for the findCountries like this:

findCountries() {
  return this.apiService.get<CountryPayload[]>("/all?fields=name,flags");
}
Enter fullscreen mode Exit fullscreen mode

DTO: Structuring Outgoing Data

DTOs, on the other hand, define a structure for the data you transfer (meaning send) to the external source. It can be a type for POST request’s body or for an object you save into IndexedDB. DTO can also represent query params for a GET request.

In HistoryRepository, we have a method saveAnswer:

saveAnswer(createHistoryRecordDto: any) {
  return this.slice
    .getState()
    .saveAnswer({ ...createHistoryRecordDto, createdAt: Date.now() });
}
Enter fullscreen mode Exit fullscreen mode

Let’s define a DTO for the createHistoryRecordDto param:

import { z } from "zod";

export const createHistoryRecordDTO = z.object({
  flagImage: z.string().nonempty(),
  countryName: z.string().nonempty(),
  userAnswer: z.string().nonempty(),
});

export type CreateHistoryRecordDTO = z.infer<typeof createHistoryRecordDTO>;
Enter fullscreen mode Exit fullscreen mode

Put this DTO inside the model folder of the History module, just like we did with CountryPayload in the previous section.

Equivalent without Zod:

export type CreateHistoryRecordDTO = {
  flagImage: string;
  countryName: string;
  userAnswer: string;
};
Enter fullscreen mode Exit fullscreen mode

This DTO consists of 3 properties:

  1. flagImage - URL of the flag picture, so we can display the flag near each answer.
  2. countryName - correct name of the country.
  3. userAnswer - name of the country the user entered.

Now we can specify a DTO type for the saveAnswer method like this:

saveAnswer(createHistoryRecordDto: CreateHistoryRecordDTO) {
  return this.slice
    .getState()
    .saveAnswer({ ...createHistoryRecordDto, createdAt: Date.now() });
}
Enter fullscreen mode Exit fullscreen mode

With the DTO in place, we can send the data out safely and predictably. Now let’s look at how we shape the data inside the app using Module Types.

Module Type: Visualize Model Layer

This is the main type of the whole module. The module type holds properties to be shown on the UI. It doesn’t necessarily mean that all the properties from this type will be represented in the interface. Some properties exist for the business logic only, some can just sit there and patiently wait to be used at some point later.

Worth mentioning that sometimes module type can replace the payload, if the backend response is structured for the UI or business logic right when we receive it. If the data needs reshaping or enriching, keep the module type and the payload separate.

For example, HistoryRecord - the module type for the History module looks like this:

import { z } from "zod";
import { createHistoryRecordDTO } from "./history.dto";

// https://zod.dev/api#extend
export const historyRecord = createHistoryRecordDTO.extend({
  createdAt: z.number(),
});

// Convert historyRecord schema into Zod array, so we can validate the response from localStorage inside the application layer
export const historyRecords = z.array(historyRecord);

export type HistoryRecord = z.infer<typeof historyRecord>;
Enter fullscreen mode Exit fullscreen mode

historyRecord schema inherits all the properties from createHistoryRecordDTO. It also adds one more createdAt timestamp indicating when the history record was created. HistoryRecord type infers the structure from the schema, as usual.

NOTE: Learn more about the extend function.

Equivalent without Zod:

export type HistoryRecord = CreateHistoryRecordDTO & {
  createdAt: number;
}
Enter fullscreen mode Exit fullscreen mode

Use HistoryRecord inside historySlice to specify the type for the history:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { HistoryRecord } from "../model";

export type HistorySlice = {
  history: HistoryRecord[]; // We add type here
  saveAnswer: (result: HistoryRecord) => void; // We add type here
  clearHistory: () => void;
};

export const historySlice = create<HistorySlice>()(
  // History slice implementation
);
Enter fullscreen mode Exit fullscreen mode

NOTE: We created historySlice in the data layer article.

This is how the HistoryRecord module type can be shown on the UI:

Diagram showing a TypeScript type HistoryRecord with fields for flagImage, countryName, userAnswer, and createdAt, mapped to a UI card displaying Nepal’s flag, country name, and a green check mark.

Prop types: Provide Data or Configuration to UI

These are just regular prop types you would specify for the components in your React app. For example, we have a sidebar with a history list widget. This widget should accept the following properties:

  • history - an array with all history records to display.
  • loading - a boolean value indicating whether we’re fetching the data at the moment.
  • error - string holding the text of the error, if any occurred.

Humbly, but enough for educational purposes. So the props type will look like this:

export type HistoryListProps = {
  history: HistoryRecord[]; // an array of module types
  loading: boolean;
  error: string;
}
Enter fullscreen mode Exit fullscreen mode

NOTE: I don’t use Zod for prop types, because this is overkill. But, of course, you can do it if you have a strong reason.

Then we provide it to the component through React.FC:

export const HistoryList: React.FC<HistoryListProps> = ({ history, loading, error }) => {
  // Widget implementation
}
Enter fullscreen mode Exit fullscreen mode

Easy, isn’t it? If you read my MVVM intro, you might remember I said this in the “Application Layer” section: “usually, each hook corresponds to a specific widget from the View layer”. Suppose we have a custom hook called useHistoryList. It fetches the history records from localStorage, handles loading and errors:

export const useHistoryList = () => {
  // Hook implementation

  return { history, loading, error };
};
Enter fullscreen mode Exit fullscreen mode

As you can see, it returns the same set of properties that we need inside the HistoryList widget. So we can do a little trick with our HistoryListProps. Instead of specifying properties manually inside the type, we can infer them from the returning object of the useHistoryList hook:

export type HistoryListProps = ReturnType<typeof useHistoryList>;
Enter fullscreen mode Exit fullscreen mode

NOTE: In the code example above, we’ve used TypeScript’s utility called ReturnType.

That one-liner isn’t just beautiful - it automatically picks up new properties from the custom hook and adds each one fully typed to your props object.

A key downside to keep in mind is that if you change the return signature of the custom hook, it will break the type and cause TypeScript linter errors. But even with this tradeoff, the object returned by the custom hook and props type will be synced. The only place to refactor is the widget.

TIP: Remember to use utils like Omit or Pick to avoid silently carrying over properties not meant for the UI.

What’s that? What if you need an additional property that the custom hook doesn’t return? I’ve got you covered:

export type HistoryListProps = ReturnType<typeof useHistoryList> & {
  customProp: string;
};
Enter fullscreen mode Exit fullscreen mode

NOTE: If you’re thinking “widgets, hooks, application layer… wtf🤯,” don’t worry. You’ll get it all if you read the MVVM intro and 2 articles about application and view layers.

We had to briefly look ahead here, because it’s impossible to explain prop types in isolation without referencing the layers that use them.

Testing

NOTE: To write unit tests I’m using Vitest.

One of the reasons I tend to use Zod is that you can test model layer entities we’ve just discussed. This catches errors that TypeScript can’t, like empty strings or missing fields, which often cause runtime bugs in real-world apps. Here’s how to verify whether our DTO is correct:

import { describe, it, expect } from "vitest";
import { createHistoryRecordDTO } from "../history.dto";

describe("createHistoryRecordDTO", () => {
  it("should parse valid input", () => {
    const valid = {
      flagImage: "flag.png",
      countryName: "Ukraine",
      userAnswer: "Ukraine",
    };

    expect(createHistoryRecordDTO.parse(valid)).toEqual(valid);
  });

  it("should fail for empty strings", () => {
    const edge = {
      flagImage: "",
      countryName: "",
      userAnswer: "",
    };

    expect(() => createHistoryRecordDTO.parse(edge)).toThrow();
  });

  it("should fail for missing fields", () => {
    expect(() => createHistoryRecordDTO.parse({})).toThrow();
    expect(() =>
      createHistoryRecordDTO.parse({ flagImage: "f.png" })
    ).toThrow();
  });

  it("should fail for wrong types", () => {
    expect(() =>
      createHistoryRecordDTO.parse({
        flagImage: 123,
        countryName: true,
        userAnswer: null,
      })
    ).toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

A piece of cake! No mocks, no spies, nothing. We just use regular Zod’s syntax, which we’d use in the production code.

One more test to verify countryPayload:

import { describe, it, expect } from "vitest";
import { countriesPayload } from "../home.dto";

// Import the single country schema
const countryPayload = countriesPayload.element;

describe("countryPayload", () => {
  it("should parse valid input", () => {
    const valid = {
      flags: {
        png: "flag.png",
        svg: "flag.svg",
        alt: "Flag of Ukraine",
      },
      name: {
        common: "Ukraine",
        official: "Ukraine",
        nativeName: {
          ukr: { official: "Україна", common: "Україна" },
        },
      },
    };

    expect(countryPayload.parse(valid)).toEqual(valid);
  });

  it("should fail for empty strings in required fields", () => {
    const invalid = {
      flags: {
        png: "",
        svg: "",
        alt: "",
      },
      name: {
        common: "",
        official: "",
        nativeName: {
          "": { official: "", common: "" },
        },
      },
    };

    expect(() => countryPayload.parse(invalid)).toThrow();
  });

  it("should fail for missing fields", () => {
    expect(() => countryPayload.parse({})).toThrow();
    expect(() =>
      countryPayload.parse({ flags: { png: "a", svg: "b", alt: "c" } })
    ).toThrow();
  });

  it("should fail for wrong types", () => {
    expect(() =>
      countryPayload.parse({
        flags: "not-an-object",
        name: 123,
      })
    ).toThrow();
    expect(() =>
      countryPayload.parse({
        flags: { png: 1, svg: 2, alt: 3 },
        name: { common: [], official: {}, nativeName: null },
      })
    ).toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

In both cases, we test for:

  • Valid input
  • Empty strings
  • Missing fields
  • Incorrect types

Now that you’ve seen testing examples for both DTO and payload, here’s a small challenge to test your understanding.

A little homework

Try to add other tests for other model layer entities using the example that I’ve provided. Of course, you will find all the tests in the source code if you get stuck. But I really encourage you to try on your own, because we learn the best when we take action!

Conclusion

If there’s one thing to take away from this article, it’s this: organize your types and take care of them. It’s a common place where things often get messy. Today, we’ve looked at the model layer, its entities, and how they help you organize your types and structure your code. You’ve learned:

  • How to type your query bodies and responses utilizing DTOs and Payloads.
  • Why we need a Module Type and how it’s used across the module.

You’ve also:

  • Revised your knowledge about prop types and how MVVM’s hook-UI relation simplifies their maintenance.
  • Got to know how we can test the model layer entities, so no invalid data slips into your app, causing unexpected bugs.

Now you have everything you need to build a robust model layer and keep your types consistent and predictable throughout the system. Additionally, Zod usage will make sure the data you manipulate in different parts of your app is valid.

What’s next?

Continue reading the next articles in the MVVM series:

Or go over the previous ones:


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