How the MobX State Management System Works in Plane Frontend Applications
Plane uses a centralized root store pattern with granular domain stores, where observable state is declared via makeObservable, mutations are wrapped in action decorators, and React components automatically re-render through the observer higher-order component, eliminating the need for boilerplate reducers or manual subscription wiring.
Plane’s frontend is a collection of React applications that rely on MobX for transparent, mutable state management. The architecture centers on a centralized root store that aggregates many granular domain-specific stores, each responsible for distinct entities like workspaces, users, issues, and filters. This design allows the UI to react precisely to state changes while maintaining type safety and clear separation between data access and business logic.
Core Architecture of the Plane MobX State Management System
Centralized Root Store Pattern
At the heart of Plane’s state layer lies CoreRootStore in apps/web/core/store/root.store.ts. This class instantiates and aggregates every domain store, making them available throughout the component tree via React context.
The root store handles cross-cutting concerns like user sessions and global UI state. When a user signs out, the resetOnSignOut() method re-instantiates every store to clear cached data and prevent information leakage between sessions.
// apps/web/core/store/root.store.ts
import { enableStaticRendering } from "mobx-react";
import { WorkItemFilterStore, UserStore } from "@plane/shared-state";
enableStaticRendering(typeof window === "undefined");
export class CoreRootStore {
workItemFilters: IWorkItemFilterStore;
user: IUserStore;
constructor() {
this.user = new UserStore(this as unknown as RootStore);
this.workItemFilters = new WorkItemFilterStore();
}
resetOnSignOut() {
// Re-create every store to discard cached state
this.user = new UserStore(this as unknown as RootStore);
this.workItemFilters = new WorkItemFilterStore();
}
}
Shared State Package vs. App-Specific Stores
Plane separates reusable domain logic from UI-specific concerns using a monorepo structure. The @plane/shared-state package contains framework-agnostic stores like WorkspaceStore and UserStore, while the web application housing CoreRootStore adds app-specific stores for issues, projects, and members. This modularization allows consistent state patterns across different Plane frontend applications without duplicating business logic.
Observable State and Reactivity Patterns
Declaring Observables with makeObservable
Plane stores use makeObservable to explicitly declare which fields should be reactive. Unlike older MobX patterns that rely on decorators, Plane uses the utility-based approach for better TypeScript support and tree-shaking.
In packages/shared-state/src/store/workspace.store.ts, the WorkspaceStore constructor registers each scalar field as an observable reference:
// packages/shared-state/src/store/workspace.store.ts
import { makeObservable, observable } from "mobx";
export class WorkspaceStore implements IWorkspaceStore {
id: string;
name: string;
createdAt: string;
updatedAt: string;
constructor(data: IWorkItemStore) {
makeObservable(this, {
id: observable.ref,
name: observable.ref,
createdAt: observable.ref,
updatedAt: observable.ref,
});
this.id = data.id;
this.name = data.name;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}
Scalar Fields vs. Collection Observables
Plane distinguishes between scalar observables using observable.ref (ideal for primitives and immutable references) and deep observables using observable (for Maps, arrays, and objects that require mutation tracking). The WorkItemFilterStore in packages/shared-state/src/store/work-item-filters/filter.store.ts demonstrates the latter pattern by storing filters in a reactive Map.
Actions and Asynchronous State Mutations
Synchronous Actions
State mutations in Plane are strictly contained within actions. The makeObservable configuration maps method names to action, ensuring all state changes occur inside MobX transactions for optimal batching and debugging.
Async Flows with runInAction
For asynchronous operations like API calls, Plane uses runInAction to safely mutate observables after async boundaries. The WorkspaceIssues store in apps/web/core/store/issue/workspace/issue.store.ts exemplifies this pattern by updating loader states before and after network requests:
// apps/web/core/store/issue/workspace/issue.store.ts
import { action, makeObservable, runInAction } from "mobx";
export class WorkspaceIssues extends BaseIssuesStore {
constructor(_rootStore: IIssueRootStore, issueFilterStore: IWorkspaceIssuesFilter) {
super(_rootStore, issueFilterStore);
makeObservable(this, {
fetchIssues: action,
fetchNextIssues: action,
});
this.workspaceService = new WorkspaceService();
}
fetchIssues = async (workspaceSlug, viewId, loadType, options) => {
try {
runInAction(() => this.setLoader(loadType));
const response = await this.workspaceService.getViewIssues(workspaceSlug, params);
this.onfetchIssues(response, options, workspaceSlug);
return response;
} catch (e) {
runInAction(() => this.setLoader(undefined));
throw e;
}
};
}
Computed Values and Selectors
Standard Computed Properties
Plane leverages MobX’s computed decorator for derived state that automatically updates when dependencies change, though the provided examples primarily showcase computedFn from mobx-utils for parameterized selectors.
Memoized Selectors with computedFn
The WorkItemFilterStore uses computedFn to create cached filter lookups. This prevents expensive recalculations when accessing specific filter configurations by entity type and ID:
// packages/shared-state/src/store/work-item-filters/filter.store.ts
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
export class WorkItemFilterStore implements IWorkItemFilterStore {
filters: Map<TWorkItemFilterKey, IWorkItemFilterInstance>;
constructor() {
this.filters = new Map();
makeObservable(this, {
filters: observable,
getOrCreateFilter: action,
});
}
getFilter = computedFn((entityType, entityId) =>
this.filters.get(this._getFilterKey(entityType, entityId))
);
}
React Integration and Component Reactivity
The Observer Pattern
Components consume stores via a custom useRootStore() hook and wrap their export with observer from mobx-react. This creates a reactive binding where the component re-renders only when the specific observables accessed during its render cycle change:
import { observer } from "mobx-react";
import { useRootStore } from "@/hooks/useRootStore";
const WorkspaceTitle = observer(() => {
const { workspaceRoot } = useRootStore();
const current = workspaceRoot.currentWorkspace;
return <h1>{current?.name ?? "Loading…"}</h1>;
});
Static Rendering Support
Plane enables server-side rendering (SSR) safety by calling enableStaticRendering(typeof window === "undefined") at the top of apps/web/core/store/root.store.ts. This prevents MobX from attempting to modify observables during the server render phase, avoiding hydration mismatches.
Store Lifecycle and Security
Store Reset on Sign-Out
To prevent data leakage between user sessions, Plane implements a complete store tear-down strategy. The CoreRootStore.resetOnSignOut() method described in apps/web/core/store/root.store.ts re-instantiates every domain store, guaranteeing that cached API responses, pending promises, and computed values are garbage collected before a new user authenticates.
Summary
- Plane uses a centralized root store (
CoreRootStore) that aggregates domain-specific stores from the@plane/shared-statepackage and app-specific modules. - Observables are explicitly declared using
makeObservablewithobservable.reffor scalars andobservablefor collections. - State mutations occur strictly within actions, with async flows using
runInActionto safely update state after network requests. - Computed selectors use
computedFnfrommobx-utilsto provide memoized, efficient data access for complex filter logic. - React components use the
observerhigher-order component to automatically track observable dependencies and re-render only when necessary. - Static rendering support is enabled via
mobx-react’senableStaticRenderingfor SSR compatibility. - Security is enforced by re-instantiating all stores on sign-out via
resetOnSignOut()to clear cached data.
Frequently Asked Questions
How does Plane handle async operations in MobX stores?
Plane wraps asynchronous API calls inside class methods decorated as action, then uses runInAction to mutate observables after await statements. This pattern appears in apps/web/core/store/issue/workspace/issue.store.ts where fetchIssues updates loading states before and after network requests, ensuring all observable mutations occur within MobX transactions.
What is the difference between observable.ref and observable in Plane stores?
Plane uses observable.ref for scalar values like strings and numbers where only reassignment needs tracking, while observable (deep observation) is used for collections like Maps and arrays. The WorkspaceStore in packages/shared-state/src/store/workspace.store.ts demonstrates observable.ref for workspace metadata, whereas filter stores use deep observables for dynamic collections.
How do React components access and react to Plane's MobX stores?
Components import observer from mobx-react and wrap their export to create a reactive boundary. They access stores via the useRootStore() hook, then read observable properties directly. The component automatically re-renders when any accessed observable changes, without requiring manual subscription management or selector functions.
Why does Plane re-instantiate stores on sign-out instead of resetting fields?
Re-instantiating stores via CoreRootStore.resetOnSignOut() guarantees complete garbage collection of cached data, pending promises, and computed memoization caches. This approach prevents memory leaks and data leakage between user sessions that could occur if stores simply reset individual fields to initial values.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →