| name | ui-developer |
| description | Use when building UI features in src/ui/. Layered architecture: Gateway (HTTP) → Repository (state) → UseCase (logic) → Presenter (MobX vm) → React (display). React is dumb. All state and logic in TypeScript classes via DI. Invoke BEFORE writing any UI code. References project-architecture for DI patterns.
|
UI Developer Guide
UI lives in src/ui/. Strict layered architecture — React is a dumb display layer. All state, logic, and data transformation live in plain TypeScript classes wired through @webiny/di. Shared types, errors, and domain models go in src/shared/.
Layer Rules
React (display only) ← reads vm, calls actions
↑ useFeature() hook
Presenter (MobX observable) ← owns vm() getter, single source of UI truth
↑ DI constructor injection
UseCase (orchestration) ← all reads and writes
↑
Repository (plain class) ← holds domain state, NOT MobX observable
↑
Gateway (HTTP calls) ← translates API responses to domain types
| Layer | MobX | Holds state | React access | DI Scope |
|---|
| Gateway | No | No | Never | Singleton |
| Repository | No | Yes | Never | Singleton |
| UseCase | No | No | Never | Transient |
| Presenter | makeAutoObservable | Yes (derived) | Via vm() only | Transient |
Result Pattern (no throws)
All layers use Result<T, E> from @webiny/stdlib — same as CLI. Nothing throws. Gateways catch HTTP errors and return Result.fail(). Use cases return Result. Presenters check result.isOk() / result.isFail() and update state accordingly. Presenters that trigger async work must use try/finally to reset loading state.
Directory Structure
src/ui/
├── features/ # Headless layer (Gateway + Repository)
│ └── {featureName}/
│ ├── abstractions/
│ │ ├── {Action}{Entity}Gateway.ts
│ │ ├── {Entity}Repository.ts
│ │ └── index.ts
│ ├── {Action}{Entity}Gateway.ts
│ ├── {Entity}Repository.ts
│ └── feature.ts
├── presentation/ # Presentation layer (Presenter + React)
│ └── {domain}/
│ └── {Page}/
│ ├── abstractions/
│ │ ├── {Page}Presenter.ts
│ │ └── index.ts
│ ├── useCases/
│ │ └── {Action}{Entity}/
│ │ ├── abstractions/
│ │ └── {Action}{Entity}UseCase.ts
│ ├── {Page}Presenter.ts
│ ├── {Page}Provider.tsx
│ ├── feature.ts
│ └── components/
│ ├── {Page}Page.tsx
│ └── {Feature}List.tsx
└── feature.ts # Root UiFeature
Gateway — HTTP Only
Calls the backend API. Returns Result<T, E>.
import { createAbstraction } from "@webiny/stdlib";
import type { Result } from "@webiny/stdlib";
export interface IProjectDto {
id: string;
name: string;
apiUrl: string;
}
export interface IProjectsGateway {
getAll(): Promise<Result<IProjectDto[], ProjectsGateway.Error>>;
create(input: ProjectsGateway.CreateInput): Promise<Result<IProjectDto, ProjectsGateway.Error>>;
}
export const ProjectsGateway = createAbstraction<IProjectsGateway>("Ui/ProjectsGateway");
export namespace ProjectsGateway {
export type Interface = IProjectsGateway;
export type ProjectDto = ;
= { : ; : ; : };
= ().;
}
Repository — Plain Class, NO MobX
Holds domain state. Exposes via getter methods. Singleton scope.
class ProjectsRepositoryImpl implements Abstraction.Interface {
private projects: Abstraction.Project[] = [];
public constructor(
private readonly gateway: ProjectsGateway.Interface,
) {}
public getProjects(): Abstraction.Project[] {
return this.projects;
}
public async loadProjects(): Promise<Result<void, Abstraction.Error>> {
const result = await this.gateway.getAll();
if (result.isFail()) {
return Result.fail(result.error);
}
this.projects = result.value;
return Result.ok(undefined);
}
}
UseCase — Orchestration
Coordinates repositories. Transient scope. Presenters call use cases, never repositories or gateways directly.
Presenter — The Only MobX Layer
Owns vm() getter. Maps domain state to view-ready data. Transient scope.
import { makeAutoObservable, runInAction, computed } from "mobx";
class ProjectListPresenterImpl implements Abstraction.Interface {
private loading = false;
public constructor(
private readonly loadProjectsUseCase: LoadProjectsUseCase.Interface,
private readonly projectsRepository: ProjectsRepository.Interface,
) {
makeAutoObservable(this, { vm: computed });
}
public get vm(): Abstraction.ViewModel {
const projects = this.projectsRepository.getProjects();
return {
loading: this.loading,
projects: projects.map(p => ({ id: p.id, name: p.name, apiUrl: p.apiUrl })),
isEmpty: projects.length === ,
};
}
load = (): <> => {
. = ;
{
..();
} {
( { . = ; });
}
};
}
Rules:
makeAutoObservable(this, { vm: computed }) in constructor
vm is a computed getter returning a plain object with view-ready data
- All methods (except
vm) are arrow function class properties (lexical this)
- Async mutations wrapped in
runInAction()
- Presenter provides correct values — views never do
value ?? "" or null coercion
React — Display Only
Zero business logic. Zero data transformation. Reads vm, calls presenter actions.
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import type { ProjectListPresenter } from "../abstractions/ProjectListPresenter.ts";
interface ProjectListPageProps {
presenter: ProjectListPresenter.Interface;
}
export const ProjectListPage = observer(function ProjectListPage({
presenter
}: ProjectListPageProps) {
const { vm } = presenter;
useEffect(() => { presenter.load(); }, [presenter]);
if (vm.loading) return <div>Loading...</div>;
if (vm.isEmpty) return <div>No projects configured.</div>;
return (
<ul>
{vm.projects.map(p => <li key={p.id}>{p.name} — {p.apiUrl}</li>)}
);
});
Provider
Connects DI to React via render props:
import type React from "react";
import { useFeature } from "../../shared/di/useFeature.ts";
import { ProjectListFeature } from "./feature.ts";
import type { ProjectListPresenter } from "./abstractions/ProjectListPresenter.ts";
interface Props {
children: (params: { presenter: ProjectListPresenter.Interface }) => React.ReactNode;
}
export function ProjectListProvider({ children }: Props) {
const { presenter } = useFeature(ProjectListFeature);
return children({ presenter });
}
Anti-Patterns
| Wrong | Right |
|---|
| Component reads from repository | Component reads from presenter.vm |
makeAutoObservable in repository | Only in presenter |
| Business logic in component | Move to presenter or use case |
| Presenter calls gateway/repository | All reads/writes through use cases |
useEffect chain for business logic | Logic in presenter; useEffect only for load-on-mount |
Class method async load() {} | Arrow property load = async () => {} |
View does value ?? "" | Presenter provides view-ready values |
| Inline complex JSX | Extract into separate component files |
| Barrel exports implementation/feature | Barrel exports abstractions only |
Feature Registration
UI features use createFeature from @webiny/stdlib with dependencies support for auto-registration.
import { createFeature } from "@webiny/stdlib";
export const ProjectsFeature = createFeature({
name: "Ui/ProjectsFeature",
register(container) {
container.register(ProjectsGateway).inSingletonScope();
container.register(ProjectsRepository).inSingletonScope();
},
});