DEV Community

Cover image for React MVVM Architecture with TanStack Router: Orchestrator
Olexandr
Olexandr

Posted on • Originally published at techwood.substack.com

React MVVM Architecture with TanStack Router: Orchestrator

Earlier in this MVVM series, we discussed that blending the UI with the logic undermines maintainability and testability. So, if we cannot mix these two, how do we actually connect them?

The answer is in the final piece of the MVVM puzzle - orchestrating component (also known as the orchestrator):

Diagram of software architecture showing Orchestrator with Data, Model, Application, and View layers.

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

In this article, you’ll learn:

  1. What’s the orchestrating component, and what is his job.
  2. How to create the orchestrator and combine the layers within it.
  3. How to test the orchestrating component.

Before we begin, I 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 country. Once they entered and sent the answer, we save it in localStorage. Then we display the answer history in the left sidebar. You can find the source code on GitHub and the Demo on CodeSandbox.

The glue of a module

Orchestrator is a simple React component that has two main tasks:

  1. It connects UI (represented by the view layer) with the business logic (represented by the application layer).
  2. It serves as an entry point into the module. For example, in TanStack Router, the orchestrator can be the route’s primary component:
import { Orchestrator } from "@/modules/Module";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/")({
  component: Orchestrator,
});
Enter fullscreen mode Exit fullscreen mode

Now, how do we implement the orchestrator?

Creation of the orchestrator

Open your IDE of choice and, inside the Home module, create a new file called Home.tsx. Your folder structure will look like this:

src
└── modules
    └── Home
        ├── application
        ├── data
        ├── model
        ├── view
        ├── Home.tsx
        └── index.ts
Enter fullscreen mode Exit fullscreen mode

NOTE: index.ts is for re-exporting. Now, when you import Home orchestrator, use @/modules/Home instead of @/modules/Home/Home (”Home” 2 times).

Inside Home.tsx, create a new functional React component:

export const Home: React.FC = () => {

};
Enter fullscreen mode Exit fullscreen mode

Now let’s call the useQuiz hook from the application layer:

import { useQuiz } from "./application/useQuiz";

export const Home: React.FC = () => {
  const quizProps = useQuiz();
};
Enter fullscreen mode Exit fullscreen mode

Last but not least, return the Quiz widget from the view layer:

import { Container } from "@mui/material";
import { Quiz } from "./view/Quiz";
import { useQuiz } from "./application/useQuiz";

export const Home: React.FC = () => {
  const quizProps = useQuiz();

  return (
    <Container sx={{ display: "flex", alignItems: "center", height: "100%" }}>
      <Quiz {...quizProps} />
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

NOTE: I’ve also wrapped the widget in the MUI’s Container component purely for styling. The orchestrating component doesn’t depend on any UI library. Feel free to use the library of your choice or not to use any.

Diagram showing orchestrator passing props from useQuiz in Application Layer to Quiz widget in View Layer.

As a cherry on top of a pie, replace the temporary mock code in the src/routes/index.tsx with your new Home orchestrator:

import { Home } from "@/modules/Home";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/")({
  component: Home,
});
Enter fullscreen mode Exit fullscreen mode

Congratulations! You’ve created the orchestrating component for the Home module. In this component, you:

  1. Took all the props returned by the useQuiz hook.
  2. Provided the props to the Quiz widget.

If you had more widgets and hooks, creating the orchestrator would follow the same steps: call the hooks and pass the results to the widgets through props.

But don’t forget about complexity and scaling aspects!

Split into sub-orchestrators when needed

When a module starts wiring 5+ independent hooks and widgets like this:

import { Container, Stack, Divider } from "@mui/material";

// Application layer (business logic)
import { useProfile } from "./application/useProfile";
import { useStats } from "./application/useStats";
import { useActivity } from "./application/useActivity";
import { useNotifications } from "./application/useNotifications";
import { useTips } from "./application/useTips";

// View layer (UI widgets)
import { ProfileCard } from "./view/ProfileCard";
import { StatsCard } from "./view/StatsCard";
import { ActivityFeed } from "./view/ActivityFeed";
import { NotificationsList } from "./view/NotificationsList";
import { TipsPanel } from "./view/TipsPanel";

export const Dashboard: React.FC = () => {
  // 1. Call hooks (application layer)
  const profileProps = useProfile();           // e.g. { user, isLoading, error, refresh }
  const statsProps = useStats();               // e.g. { totals, delta, isLoading, error }
  const activityProps = useActivity();         // e.g. { items, isLoading, error }
  const notificationsProps = useNotifications(); // e.g. { items, markRead, isLoading, error }
  const tipsProps = useTips();                 // e.g. { tips, dismissTip, isLoading, error }

  // 2. Wire results to widgets (view layer)
  return (
    <Container sx={{ py: 2 }}>
      <Stack spacing={2}>
        <ProfileCard {...profileProps} />
        <Divider />
        <StatsCard {...statsProps} />
        <ActivityFeed {...activityProps} />
        <NotificationsList {...notificationsProps} />
        <TipsPanel {...tipsProps} />
      </Stack>
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

Consider splitting them into sub‑orchestrators:

import React from "react";
import { useTips } from "./application/useTips";
import { TipsPanel } from "./view/TipsPanel";

export const Tips: React.FC = () => {
  const tipsProps = useTips(); // e.g. { tips, dismissTip, isLoading, error }
  return <TipsPanel {...tipsProps} />;
};
Enter fullscreen mode Exit fullscreen mode

Then the main orchestrating component would connect other sub-orchestrators instead of hooks and widgets directly:

import { Container, Stack } from "@mui/material";
import { Tips } from "./Tips";

export const Dashboard: React.FC = () => {
  return (
    <Container sx={{ py: 2 }}>
      <Stack spacing={2}>
        {/* ...other sub‑orchestrators... */}
        <Tips />
      </Stack>
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

Okay, we’ve discussed how to create an orchestrator and split it into more granular parts if it wires 5+ widgets and hooks. Now it’s time for tests 🧪.

Test the orchestrator

NOTE: As usual, I’m using Vitest and React Testing Library to write unit tests.

Test code for the orchestrating component is as simple as its actual implementation:

import { render, screen } from "@testing-library/react";
import { describe, it, vi, expect, beforeEach } from "vitest";

// Capture props passed to Quiz for assertions
let lastQuizProps: any = null;

// Mock the useQuiz hook to control props returned to Home
vi.mock("./application/useQuiz", () => ({
  useQuiz: vi.fn(),
}));

// Mock Quiz view to a simple component we can assert against
vi.mock("./view/Quiz", () => ({
  Quiz: (props: any) => {
    lastQuizProps = props;
    const label = props?.country?.name ?? "no-country";
    return <div data-testid="quiz">{label}</div>;
  },
}));

// Imports go after mocks to ensure the mocked modules are applied before the actual modules are imported and executed.
import { Home } from "./Home";
import { useQuiz } from "./application/useQuiz";

describe("Home", () => {
  beforeEach(() => {
    lastQuizProps = null;
  });

  it("renders Quiz with props returned by useQuiz", () => {
    const mockProps = {
      country: { flag: "https://example.com/fr.png", name: "France" },
      isLoading: false,
      error: null,
      submissionError: "",
      form: {} as any,
      onSubmit: vi.fn(),
      result: undefined,
    };

    vi.mocked(useQuiz).mockReturnValue(mockProps as any);

    render(<Home />);

    // Quiz should be rendered and receive the same props
    expect(!!screen.getByTestId("quiz")).toBe(true);
    expect(screen.getByTestId("quiz").textContent).toBe("France");
    expect(lastQuizProps).toMatchObject(mockProps);
  });

  it("renders Quiz even when country is null and loading is true", () => {
    const mockProps = {
      country: null,
      isLoading: true,
      error: null,
      submissionError: "",
      form: {} as any,
      onSubmit: vi.fn(),
      result: undefined,
    };

    vi.mocked(useQuiz).mockReturnValue(mockProps as any);

    render(<Home />);

    // Still renders Quiz and shows our fallback label
    expect(!!screen.getByTestId("quiz")).toBe(true);
    expect(screen.getByTestId("quiz").textContent).toBe("no-country");
    expect(lastQuizProps).toMatchObject(mockProps);
  });

  it("passes error from useQuiz down to Quiz", () => {
    const error = new Error("Network failed");
    const mockProps = {
      country: null,
      isLoading: false,
      error,
      submissionError: "",
      form: {} as any,
      onSubmit: vi.fn(),
      result: undefined,
    };

    vi.mocked(useQuiz).mockReturnValue(mockProps as any);

    render(<Home />);

    // Quiz is rendered and receives the error prop
    expect(!!screen.getByTestId("quiz")).toBe(true);
    expect(lastQuizProps.error).toBe(error);
  });
});
Enter fullscreen mode Exit fullscreen mode

In our case, testing the orchestrating component is just passing props from hooks to widgets. No internal states or side effects. Easy and clean, just like we all enjoy!

NOTE: My task is to show you the basic principles of testing the orchestrator. I’m not focused on creating comprehensive production-ready test cases. You can add more tests for better coverage.

Testing is done! If you read my previous articles in this series, you’ve probably already guessed what’s coming next 🙂.

Homework

Create a History orchestrator inside the History module. The orchestrator should:

  • Take the object returned from the useHistoryList hook. This is the hook you had to create as homework in the application layer article.
  • Provide the object through props to the HistoryList widget. This is the widget you had to create as homework in the view layer article.

If you get stuck, check the ready task in the source code.

MVVM brainteaser is done!

Wow, just wow…

I can’t believe you’ve reached this point 🥹

You’ve not only studied the orchestrator component, you’ve finished the entire MVVM journey! By finishing this article and the whole series, you’ve learned:

  1. How to wire the logic with the UI in the right way.
  2. How to build more maintainable frontend applications with a clear separation of concerns.
  3. How easy it is to test the React applications when each layer has one concrete task.

What’s next?

Go and build something cool with your new knowledge! 😄

Or you can always revisit the previous articles, in case you forgot some details:


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

See ya soon 👋

Top comments (0)