How @plane/shared-state Manages MobX Stores: A Deep Dive into Plane's State Architecture
The @plane/shared-state package implements MobX stores as plain TypeScript classes using makeObservable to define observables, action for state mutations, and computed/computedFn for derived data, without enforcing global singletons.
The @plane/shared-state package sits at the heart of Plane's client-side state management, providing a collection of reactive stores built on MobX. These stores handle everything from workspace metadata to complex work-item filtering logic, following a consistent pattern that balances performance with developer ergonomics. Understanding how this package manages MobX stores reveals the architectural decisions behind Plane's reactive UI layer.
Core Store Architecture
Each store in @plane/shared-state follows the same fundamental structure: a TypeScript class whose properties become MobX observables through explicit makeObservable calls in the constructor.
WorkspaceStore: Simple Observable References
The WorkspaceStore demonstrates the minimal pattern for basic data models. Located at src/store/workspace.store.ts, it holds workspace metadata as observable references to avoid unnecessary deep conversion:
// From src/store/workspace.store.ts
makeObservable(this, {
id: observable.ref,
name: observable.ref,
logo_url: observable.ref,
created_at: observable.ref,
updated_at: observable.ref,
})
Primitive values use observable.ref rather than plain observable, which prevents MobX from recursively making properties observable—an optimization since these fields are simple strings or timestamps.
UserStore: Maps and Complex State
The UserStore (src/store/user.store.ts) illustrates handling more complex structures. It tracks the current user alongside a Map of workspaces, with different observable strategies for each:
makeObservable(this, {
user: observable.ref,
workspaces: observable, // Map gets full observable treatment
isLoading: observable,
errors: observable,
})
The workspaces Map remains observable for reactivity, allowing UI components to respond when workspaces are added or removed via standard Map operations.
WorkItemFilterStore: Action-Heavy State Management
For interactive state requiring frequent updates, the WorkItemFilterStore (src/store/work-item-filters/filter.store.ts) demonstrates the action pattern. Its filters Map holds per-entity filter instances, with methods wrapped as MobX actions:
// Key methods declared as actions in makeObservable
getOrCreateFilter: action,
resetExpression: action,
updateFilterValueFromSidebar: action,
deleteFilter: action,
Actions batch notifications, preventing excessive re-renders during complex filter operations.
FilterConfig: Computed Properties and memoizedFn
The rich-filter system in src/store/rich-filters/config.ts showcases derived state through computed values and computedFn from mobx-utils:
makeObservable(this, {
// Observables
selectedValues: observable,
selectedOperations: observable,
// Computed values
allEnabledSupportedOperators: computed,
firstOperator: computed,
// Actions
mutate: action,
})
Methods like getOperatorConfig and getAllDisplayOperatorOptionsByValue use computedFn for efficient memoization—these expensive lookups recompute only when their observable inputs change.
Reactive Patterns in Practice
Observable Strategy by Data Type
| Data Type | MobX Decorator | Rationale |
|---|---|---|
| Primitives (string, boolean, Date) | observable.ref |
Avoid deep conversion overhead |
| Collections (Map, Set, Array) | observable |
Enable reactive iteration and structural changes |
| Class instances | observable.ref or nested makeObservable |
Depends on reactivity needs |
Action Boundaries
State mutations flow exclusively through action-decorated methods. This constraint enables MobX's transaction batching: multiple property changes within a single action notify observers only once. The updateFilterValueFromSidebar method in WorkItemFilterStore may touch several observables, but components re-render once, not per mutation.
Computed Optimization
Heavy derivation logic uses computedFn rather than standard computed when the computation depends on arguments. Standard computed properties work for derivations from fixed observables; computedFn extends this to parameterized functions with proper memoization keyed by arguments.
Store Instantiation and Lifecycle
Unlike many MobX patterns, @plane/shared-state deliberately avoids global singletons. Consumers instantiate stores as needed:
// Component-level or higher store instantiation
import { WorkspaceStore, UserStore, WorkItemFilterStore } from "@plane/shared-state";
// Simple store creation
const workspace = new WorkspaceStore({
id: "w-123",
name: "Engineering",
logo_url: "/assets/engineering.svg",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
This instantiation pattern supports testing and server-side rendering scenarios where global state causes conflicts. The WorkItemFilterStore internally manages its own child objects—FilterInstance creations happen through getOrCreateFilter, which handles the lifecycle of per-entity filter state.
Practical Usage Examples
Reactive Workspace Display
import { observer } from "mobx-react-lite";
import { WorkspaceStore } from "@plane/shared-state";
const workspace = new WorkspaceStore({ /* ... */ });
const WorkspaceHeader = observer(() => (
<header>
<h1>{workspace.name}</h1>
<span>Last updated: {new Date(workspace.updated_at).toLocaleDateString()}</span>
</header>
));
The observer HOC connects React's render cycle to MobX's dependency tracking—no manual subscription management required.
User and Workspace Coordination
const userStore = new UserStore();
// Set authenticated user
userStore.user = { id: "u-42", email: "dev@plane.so" };
// Associate workspace with user
userStore.workspaces.set(workspace.id, workspace);
// Reactivity extends to Map operations
console.log(userStore.workspaces.size); // Triggers updates on add/delete
Complex Filter Management
const filterStore = new WorkItemFilterStore();
// Initialize filter for a specific project view
const projectFilter = filterStore.getOrCreateFilter({
entityId: "proj-789",
entityType: "PROJECT",
showOnMount: true,
});
// Sidebar interaction adds filter condition
filterStore.updateFilterValueFromSidebar("PROJECT", "proj-789", {
property: "priority",
operator: "is_any_of",
value: ["urgent", "high"],
});
// Reset to initial state
filterStore.resetExpression("PROJECT", "proj-789");
The updateFilterValueFromSidebar method encapsulates the complexity of converting UI interactions into the internal filter expression format, handling AND/OR logic and operator validation transparently.
Package Export Structure
All stores surface through src/store/index.ts with organized namespaces:
// From src/store/index.ts
export * from "./user.store";
export * from "./workspace.store";
export * from "./instance.store";
export * from "./work-item-filters/filter.store";
export * from "./rich-filters";
This single entry point enables clean imports while maintaining internal modularity.
Summary
@plane/shared-stateimplements MobX stores as explicit TypeScript classes withmakeObservabledeclarations rather than decoratorsobservable.refoptimizes primitive storage whileobservableenables reactive Maps and collections- Actions batch mutations to minimize re-renders, with
computedandcomputedFnproviding efficient derived data - No singleton enforcement allows flexible instantiation patterns for testing and SSR compatibility
- File locations:
src/store/workspace.store.ts,src/store/user.store.ts,src/store/work-item-filters/filter.store.ts,src/store/rich-filters/config.ts
Frequently Asked Questions
What MobX version does @plane/shared-state use?
The package uses MobX v6 with the explicit makeObservable API rather than legacy decorators. This choice aligns with modern MobX recommendations and avoids Babel/TypeScript decorator configuration complexity. The mobx-utils companion package provides computedFn for memoized function computations.
Why use observable.ref instead of observable for primitives?
observable.ref treats values as opaque references, skipping MobX's automatic deep conversion. For WorkspaceStore fields like id and created_at, this prevents the overhead of making every string character or Date method reactive. The optimization matters for stores instantiated frequently across large applications.
How does WorkItemFilterStore handle multiple entity types?
The store maintains an internal filters Map keyed by composite identifiers combining entityType and entityId. The getOrCreateFilter method implements a factory pattern, returning existing instances or creating new FilterInstance objects with their own MobX observables. This design isolates filter state per view while sharing the management infrastructure.
Can stores be used outside React components?
Yes—the MobX stores have no React dependency. The observable-action-computed pattern works with any JavaScript consumer. Plane likely uses these stores in non-React contexts such as background workers, CLI tools, or server-side data preparation. React integration happens through mobx-react-lite's observer, not through the store implementation itself.
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 →