This example demonstrates how March Hare's primitives map cleanly onto
Feature Sliced Design (FSD). Each
March Hare concept corresponds to one FSD layer; the import graph
flows strictly downward (app → features → shared), enforced by
eslint-plugin-boundaries — see eslint.config.js.
src/example/
├── app/ ← the host. One App() declaration, one Boundary, all routes.
├── features/ ← user-facing behaviours. Each feature owns its own scope.
└── shared/ ← reusable building blocks: types, resources, theme, utils.
The deployable host. There is exactly one App() per deployable
(see app/utils.ts), and the root <app.Boundary> wraps every page.
The App owns the Env ({ apiBase } here), and pages compose feature
components into routes.
The page itself (app/pages/cattery/) follows the standard March Hare
shape:
types.ts—Model,Actionsclass,Multicastclass when needed.actions.ts—useActions()hook that wires handlers to the model.index.tsx— the React component that consumes the hook.styles.ts— Emotion CSS pinned to design tokens fromshared/theme.
Each feature is a slice of behaviour, isolated and reusable. Two are shown here:
features/add-cat/— the "Add a cat" button. Fetches a cat image from the API, dispatches aBroadcast.Cat.Addedevent when done. Uses model annotations (context.actions.annotate(...)) for the loading state — the view readsactions.inspect.image.pending()instead of a hand-rolled boolean flag.features/cat-card/— presentational component for a single cat. Pure props in, JSX out.
Every feature owns its own multicast scope via shared.Scope<Envs, typeof Multicast>() declared in utils.ts. The component renders
inside <scope.Boundary> so any multicast action defined on the
feature's Multicast class stays confined to that feature's subtree.
The scope handle uses shared.Scope (not app.Scope) so the feature
can run under different Apps without binding to a single host. The
multicast surface is declared up-front even when no actions are
dispatched yet — the scope is the structural contract.
Feature folder layout:
features/<slice>/
├── types.ts ← Model, Actions, Multicast classes; Props for presentational ones.
├── utils.ts ← the `scope` handle: shared.Scope<Envs, typeof Multicast>()
├── actions.ts ← useActions() hook (optional, omitted for presentational features)
└── index.tsx ← React component; wraps children in <scope.Boundary>.
Feature imports may only reach into shared/ — they must never
import from app/ or from a sibling feature. The boundaries lint
fails CI if a feature breaks this rule.
Reusable, host-agnostic building blocks:
shared/types/— theEnvnamespace for per-App ambient state, theEnvsunion (used by everything outsideapp/), thePayloadnamespace for cross-feature data, and theBroadcastnamespace housing global actions (e.g.Broadcast.Cat.Added).shared/resources/— remote data viashared.Resource. Exposed through the barrelshared/resources/index.tsso callers reach forresource.cat.image()rather than deep paths. Each resource takesEnvsas its first generic so it stays reusable.shared/theme/— design tokens (colour, spacing, font, radius, shadow). Sizes follow axs / s / m / l / xl / xxlscale.shared/components/— presentational atoms (e.g. theButtonwrapper around antd).shared/utils/— pure helpers (e.g. random name generation).
Shared code never imports from features/ or app/. It may import
from itself.
Env.Cat is the concrete per-App Env shape. Anything outside app/
— every feature, every shared resource — must reach for
the Envs alias instead:
import { type Envs } from "@example/shared/types/index.ts";
export const image = shared.Resource<Envs, Cat.Response>((context) => ...);Envs is currently Env.Cat, but in a real codebase it would widen to
a union of every App's Env shape (Env.Web | Env.Mobile | ...). Code
written against Envs keeps compiling when a new host is added; code
written against a specific Env.X does not.
Once Envs is a union, anything reading a key that lives on only one
arm needs to narrow first. The standard tool is a user-defined type
guard:
declare it once next to the union and reuse it at every site that needs
the narrowing.
// shared/types/index.ts
export type Envs = Env.Web | Env.Mobile;
export function isWeb(env: Envs): env is Env.Web {
return "document" in env;
}
export function isMobile(env: Envs): env is Env.Mobile {
return "deviceId" in env;
}// shared/resources/cat/index.ts
import { isWeb, type Envs } from "@example/shared/types/index.ts";
export const image = shared.Resource<Envs, Cat.Response>((context) => {
const referer = isWeb(context.env) ? context.env.document.referrer : null;
return ky
.get(`${context.env.apiBase}/images/search`, {
headers: referer ? { Referer: referer } : undefined,
signal: context.controller.signal,
})
.json<Cat.Response>();
});Without the guard the TypeScript checker rejects context.env.document
because document is not on every arm of the union. With it, the narrowed
branch reads the Web-only field directly while the other arms compile
against whatever they actually expose — no as, no optional chains
papering over the difference.
| FSD concept | March Hare primitive |
|---|---|
| Host (app layer) | App<Env>() — one per deployable |
| Feature scope | shared.Scope<Envs, typeof Multicast>() |
| Shared resources | shared.Resource<Envs, T, P>(fetcher) |
| Cross-cutting bus | Distribution.Broadcast actions in shared/types |
| Feature-private bus | Distribution.Multicast actions in <feature>/types |
| Ambient host state | Env (per-Boundary) — never useContext()-only |
The rule of thumb from the top-level README applies: one App() per
deployable; everything inside it is a component; a component that needs
a private channel reaches for shared.Scope, never another App().
- Unit tests (
*.test.{ts,tsx}) live alongside the code they cover — e.g.features/cat-card/index.test.tsx. They usevitest+@testing-library/reactand run underhappy-dom. - Integration tests (
*.integration.tsx) drive the wholeRootvia the same testing-library setup — e.g.app/pages/cattery/index.integration.tsx. They mockglobalThis.fetchto exercise the resource layer end-to-end. - End-to-end tests live in the top-level
tests/folder and run under Playwright against the realvite devserver.
Run everything with make checks.