How Plane's Custom Views and Filtering System Works Under the Hood: A Technical Deep Dive

Plane's custom views persist filter trees as compact JSON in rich_filters, using a bidirectional WorkItemFiltersAdapter to convert between the external storage format and an internal MobX tree that powers the reactive UI.

Plane's custom views and filtering system in the makeplane/plane repository provides a sophisticated way to save, share, and dynamically update filtered perspectives of work items. The implementation centers on three tightly-coupled layers: a domain model defining the data shape, a state management layer handling format conversion, and a service layer managing persistence. This architecture enables complex, nested filtering logic while maintaining a clean separation between the stored JSON representation and the reactive UI state.

The Three-Layer Architecture

The system is organized into distinct layers that handle specific responsibilities:

Data Model and Filter Schema

At the core of Plane's custom views is the IProjectView interface, which defines the structure of a saved view including its filter expression and display preferences.

In packages/types/src/views.ts, the view model is defined as:

export interface IProjectView {
  id: string;
  name: string;
  description: string;
  rich_filters: TWorkItemFilterExpression;   // saved filter tree
  display_filters: IIssueDisplayFilterOptions;
  display_properties: IIssueDisplayProperties;
  query: IIssueFilterOptions;                // legacy flat filter params
  // …other meta fields
}

The rich_filters field stores the compact external representation—a JSON object where each key encodes property__operator and the value contains the filter criteria. This format is optimized for database storage and API transmission, differing from the tree structure used by the UI components.

Converting Between External and Internal Filter Trees

The WorkItemFiltersAdapter class in packages/shared-state/src/store/work-item-filters/adapter.ts serves as the translation layer between the compact JSON stored in the database and the hierarchical tree required by the UI.

class WorkItemFiltersAdapter extends FilterAdapter<TWorkItemFilterProperty, TWorkItemFilterExpression> {
  // external → internal
  toInternal(external: TWorkItemFilterExpression) {
    if (!external || isEmpty(external)) return null;
    return this._convertExpressionToInternal(external);
  }

  // internal → external
  toExternal(internal: TFilterExpression<TWorkItemFilterProperty>) {
    if (!internal) return {};
    return this._convertExpressionToExternal(internal);
  }
}

The toInternal method parses the external JSON and constructs MobX-ready nodes using createConditionNode and createAndGroupNode, recognizing whether a node represents a simple condition or an AND group. Conversely, toExternal serializes the UI tree back to the compact JSON format for persistence.

Building Filter Expressions from UI Conditions

When users interact with the filter UI, their selections are captured as an array of TWorkItemFilterCondition objects. The system converts these into the storable JSON format using buildWorkItemFilterExpressionFromConditions in packages/shared-state/src/utils/work-item-filters.helper.ts:

export const buildWorkItemFilterExpressionFromConditions = (
  params: Omit<TBuildFilterExpressionParams<...>, "adapter">
): TWorkItemFilterExpression | undefined => {
  const workItemFilterExpression = buildTempFilterExpressionFromConditions({
    ...params,
    adapter: workItemFiltersAdapter,
  });
  if (!workItemFilterExpression) console.error("Failed to build work item filter expression");
  return workItemFilterExpression;
};

This utility delegates to the generic helper in packages/shared-state/src/utils/rich-filter.helper.ts, which is shared between rich issue filters and work-item filters, ensuring consistency across the application.

Persisting Views via the API Service

The WorkspaceViewService class in packages/services/src/workspace/view.service.ts provides a thin wrapper around the REST API endpoints for view management:

class WorkspaceViewService extends APIService {
  async create(workspaceSlug: string, data: Partial<IWorkspaceView>) {
    return this.post(`/api/workspaces/${workspaceSlug}/views/`, data)
      .then(r => r?.data)
      .catch(e => { throw e?.response?.data; });
  }
  // update, list, retrieve, destroy …
}

When creating or updating a view, the service receives the rich_filters object that was built by the helper utilities. The server stores this JSON verbatim without schema validation on the frontend, enabling flexible filter definitions that can evolve with the product.

React Integration and State Management

Plane leverages MobX for reactive state management, exposing stores through custom React hooks. The useProjectView hook in apps/web/core/hooks/store/use-project-view.ts provides access to the project-view store, while useWorkItemFilters in apps/web/core/hooks/store/work-item-filters/use-work-item-filters.ts exposes the filter store.

The modal component in apps/web/core/components/views/modal.tsx demonstrates the integration pattern:

const { createView, updateView } = useProjectView();
const { resetExpression } = useWorkItemFilters();

// On submit:
if (!data) await createView(workspaceSlug, projectId, payload);
else {
  const viewDetails = await updateView(workspaceSlug, projectId, data.id, payload);
  resetExpression(EIssuesStoreType.PROJECT_VIEW, viewDetails.id, viewDetails.rich_filters);
}

Both stores are MobX observable objects; components reading workItemFilters.expression automatically re-render when the view updates, eliminating the need for manual subscription management.

End-to-End Workflow Example

The complete lifecycle of a custom view follows this sequence:

  1. Retrieval: WorkspaceViewService.retrieve fetches the view JSON including rich_filters.
  2. Conversion: The adapter's toInternal method transforms the saved JSON into an internal filter tree.
  3. Rendering: UI components read from the MobX store to display current filter states.
  4. Mutation: User changes trigger store actions that mutate the tree reactively.
  5. Serialization: On save, buildWorkItemFilterExpressionFromConditions converts the tree back to external JSON.
  6. Persistence: WorkspaceViewService.update transmits the JSON to the backend.

Summary

  • Plane's custom views combine filter trees (rich_filters) with display settings to create persistent, shareable work item perspectives.
  • Bidirectional conversion between compact JSON and internal trees is handled by WorkItemFiltersAdapter in packages/shared-state/src/store/work-item-filters/adapter.ts.
  • State management uses MobX stores exposed via React hooks like useWorkItemFilters and useProjectView.
  • Persistence layer relies on WorkspaceViewService to communicate with REST endpoints at /api/workspaces/${slug}/views/.
  • Filter building utilities in work-item-filters.helper.ts bridge the gap between UI condition arrays and storable JSON expressions.

Frequently Asked Questions

What is the difference between rich_filters and query in Plane's view model?

The rich_filters field stores modern, hierarchical filter trees as TWorkItemFilterExpression objects, supporting nested logical groups and complex operators. The query field contains legacy flat filter parameters (IIssueFilterOptions) used for backward compatibility with older view implementations. New code should exclusively use rich_filters for filter storage and retrieval.

How does Plane handle complex nested filters like "(Priority = High AND State = Todo) OR Assignee = Me"?

Plane represents nested logic using the internal TFilterExpression tree structure, where createAndGroupNode and createConditionNode build hierarchical representations. The WorkItemFiltersAdapter serializes these trees to flat JSON keys using the __ delimiter (e.g., priority__eq, state__eq) combined with grouping logic, then deserializes them back to trees when loading views.

Can developers create custom views programmatically outside the standard UI?

Yes, developers can instantiate WorkspaceViewService directly and pass manually constructed rich_filters objects. Using buildWorkItemFilterExpressionFromConditions with the workItemFiltersAdapter ensures proper format conversion, though the service accepts any valid TWorkItemFilterExpression JSON that matches the expected schema defined in packages/types/src/view-props.ts.

How does the filter store synchronize across multiple components?

The workItemFilters store in packages/shared-state/src/store/work-item-filters/filter.store.ts is a MobX observable that maintains a single source of truth for the current filter expression. When resetExpression is called (as seen in apps/web/core/components/views/modal.tsx), all subscribed components receive the update through MobX's reactive system, ensuring UI consistency without prop drilling or manual event emitters.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →