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):
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:
What’s the orchestrating component, and what is his job.
How to create the orchestrator and combine the layers within it.
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:
It connects UI (represented by the view layer) with the business logic (represented by the application layer).
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, });
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
NOTE:
index.ts
is for re-exporting. Now when you importHome
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 = () => {
};
Now let’s call the useQuiz hook from the application layer:
import { useQuiz } from "./application/useQuiz";
export const Home: React.FC = () => {
const quizProps = useQuiz();
};
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>
);
};
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.
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,
});
Congratulations! You’ve created the orchestrating component for the Home
module. In this component, you:
Took all the props returned by the
useQuiz
hook.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>
);
};
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} />;
};
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>
);
};
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);
});
});
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:
How to wire the logic with the UI in the right way.
How to build more maintainable frontend applications with a clear separation of concerns.
How easy it is to test the React applications when each layer has 1 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:
React MVVM Architecture with TanStack Router: An Introduction
React MVVM Architecture with TanStack Router: Application Layer
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
See ya soon 👋