Get Glyph
Warning This documentation is still a work in progress. Some details may be out of date depending on the version of Glyph you are using, but it is being actively reviewed and improved.
Documentation AI Assistant Development Licensing

Documentation

React Contexts

State management via React Context

Overview

Glyph uses React Context for all global state management. No Redux, no Zustand—just plain React.

Note

Contexts are layered: SpaceContext wraps FileTreeContext wraps ViewContext, etc.

Context Hierarchy

function App() {
  return (
    <SpaceProvider>           {/* Space lifecycle */}
      <FileTreeProvider>      {/* Files, tags, active file */}
        <ViewProvider>        {/* Active view document */}
          <UIProvider>        {/* Sidebar, search state */}
            <EditorProvider>  {/* TipTap editor instance */}
              <AppShell />
            </EditorProvider>
          </UIProvider>
        </ViewProvider>
      </FileTreeProvider>
    </SpaceProvider>
  );
}

SpaceContext

Location: src/contexts/SpaceContext.tsx

Manages space lifecycle (create, open, close).

State Shape

interface SpaceContextValue {
  // App metadata
  info: AppInfo | null;             // App name, version
  
  // Current space
  spacePath: string | null;         // '/Users/me/my-space'
  spaceSchemaVersion: number | null; // 1
  
  // History
  lastSpacePath: string | null;     // For "Continue" button
  recentSpaces: string[];           // Up to 20 recent paths
  
  // Index state
  isIndexing: boolean;              // Index rebuild in progress
  
  // Lifecycle
  settingsLoaded: boolean;          // Settings loaded from disk
  error: string;                    // Error message
  setError: (error: string) => void;
  
  // Actions
  onOpenSpace: () => Promise<void>;
  onOpenSpaceAtPath: (path: string) => Promise<void>;
  onContinueLastSpace: () => Promise<void>;
  onCreateSpace: () => Promise<void>;
  closeSpace: () => Promise<void>;
  startIndexRebuild: () => Promise<void>;
}

Usage

import { useSpace } from '@/contexts/SpaceContext';

function WelcomeScreen() {
  const { onOpenSpace, onContinueLastSpace, lastSpacePath } = useSpace();
  
  return (
    <div>
      <Button onClick={onOpenSpace}>Open Space</Button>
      
      {lastSpacePath && (
        <Button onClick={onContinueLastSpace}>
          Continue: {lastSpacePath}
        </Button>
      )}
    </div>
  );
}
function Sidebar() {
  const { spacePath, isIndexing } = useSpace();
  
  if (!spacePath) {
    return <WelcomeScreen />;
  }
  
  return (
    <div>
      {isIndexing && <IndexingSpinner />}
      <FileTreePane />
    </div>
  );
}

FileTreeContext

Location: src/contexts/FileTreeContext.tsx

Manages file browser state and tag index.

State Shape

interface FileTreeContextValue {
  // File tree data
  rootEntries: FsEntry[];                    // Files/dirs at root
  childrenByDir: Record<string, FsEntry[]>;  // Cached children by dir path
  expandedDirs: Set<string>;                 // Which dirs are expanded
  
  // Updaters (for hooks to modify state)
  updateRootEntries: (next: FsEntry[] | ((prev: FsEntry[]) => FsEntry[])) => void;
  updateChildrenByDir: (next: ...) => void;
  updateExpandedDirs: (next: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
  
  // Active file
  activeFilePath: string | null;   // 'notes/example.md'
  setActiveFilePath: (path: string | null) => void;
  
  // Derived state (computed from activeFilePath)
  activeNoteId: string | null;     // Same as activeFilePath if .md
  activeNoteTitle: string | null;  // Filename without extension
  
  // Tag index
  tags: TagCount[];                // [{ tag: 'research', count: 42 }]
  tagsError: string;
  refreshTags: () => Promise<void>;
}

Usage

import { useFileTreeContext } from '@/contexts/FileTreeContext';

function FileTreePane() {
  const { rootEntries, expandedDirs } = useFileTreeContext();
  
  return (
    <div>
      {rootEntries.map(entry => (
        <FileTreeItem
          key={entry.rel_path}
          entry={entry}
          isExpanded={expandedDirs.has(entry.rel_path)}
        />
      ))}
    </div>
  );
}
function useFileTree() {
  const { updateExpandedDirs } = useFileTreeContext();
  
  const toggleDir = (dirPath: string) => {
    updateExpandedDirs(prev => {
      const next = new Set(prev);
      if (next.has(dirPath)) {
        next.delete(dirPath);
      } else {
        next.add(dirPath);
      }
      return next;
    });
  };
  
  return { toggleDir };
}

ViewContext

Location: src/contexts/ViewContext.tsx

Manages “view documents” (folder, tag, search, database views).

State Shape

interface ViewContextValue {
  activeViewDoc: ViewDoc | null;  // Current view (folder, tag, etc.)
  setActiveViewDoc: (doc: ViewDoc | null) => void;
  
  isLoadingView: boolean;
  viewError: string;
}

type ViewDoc =
  | FolderViewDoc
  | TagViewDoc
  | SearchViewDoc
  | DatabaseViewDoc;

interface FolderViewDoc {
  type: 'folder';
  dir: string;
  files: FsEntry[];
  subfolders: FolderViewFolder[];
  note_previews: ViewNotePreview[];
}

Usage

import { useViewContext } from '@/contexts/ViewContext';

function MainContent() {
  const { activeViewDoc } = useViewContext();
  
  if (activeViewDoc?.type === 'folder') {
    return <FolderView doc={activeViewDoc} />;
  }
  
  if (activeViewDoc?.type === 'database') {
    return <DatabasePane doc={activeViewDoc} />;
  }
  
  return <MarkdownEditorPane />;
}

UIContext

Location: src/contexts/UIContext.tsx

Manages UI chrome state (sidebar, search, modals).

State Shape

interface UIContextValue {
  // Sidebar
  activePane: 'files' | 'tags' | 'ai' | 'tasks';
  setActivePane: (pane: 'files' | 'tags' | 'ai' | 'tasks') => void;
  
  isSidebarCollapsed: boolean;
  toggleSidebar: () => void;
  
  // Command palette (Cmd+K)
  isCommandPaletteOpen: boolean;
  openCommandPalette: () => void;
  closeCommandPalette: () => void;
  
  // Search
  searchQuery: string;
  setSearchQuery: (query: string) => void;
  
  // Preview pane
  activePreviewPath: string | null;
  setActivePreviewPath: (path: string | null) => void;
}

Usage

function SidebarHeader() {
  const { activePane, setActivePane } = useUIContext();
  
  return (
    <div>
      <IconButton
        onClick={() => setActivePane('files')}
        data-active={activePane === 'files'}
      >
        <FileIcon />
      </IconButton>
      
      <IconButton
        onClick={() => setActivePane('ai')}
        data-active={activePane === 'ai'}
      >
        <SparkleIcon />
      </IconButton>
    </div>
  );
}
function App() {
  const { isCommandPaletteOpen, openCommandPalette } = useUIContext();
  
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        openCommandPalette();
      }
    };
    
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [openCommandPalette]);
  
  return (
    <>
      <AppShell />
      {isCommandPaletteOpen && <CommandPalette />}
    </>
  );
}

EditorContext

Location: src/contexts/EditorContext.tsx

Manages TipTap editor instance.

State Shape

import type { Editor } from '@tiptap/react';

interface EditorContextValue {
  editor: Editor | null;              // TipTap editor instance
  setEditor: (editor: Editor | null) => void;
  
  isEditing: boolean;                 // Focus state
  saveState: 'saved' | 'saving' | 'unsaved' | 'error';
  setSaveState: (state: 'saved' | 'saving' | 'unsaved' | 'error') => void;
}

Usage

import { useEditor } from '@tiptap/react';
import { useEditorContext } from '@/contexts/EditorContext';

function MarkdownEditorPane() {
  const { setEditor, setSaveState } = useEditorContext();
  
  const editor = useEditor({
    extensions: [/* ... */],
    onUpdate: () => {
      setSaveState('unsaved');
      debouncedSave();
    },
    onFocus: () => setIsEditing(true),
    onBlur: () => setIsEditing(false),
  });
  
  useEffect(() => {
    setEditor(editor);
    return () => setEditor(null);
  }, [editor, setEditor]);
  
  return <EditorContent editor={editor} />;
}
function EditorRibbon() {
  const { editor } = useEditorContext();
  
  if (!editor) return null;
  
  return (
    <div>
      <Button onClick={() => editor.chain().focus().toggleBold().run()}>
        <BoldIcon />
      </Button>
    </div>
  );
}

Custom Context Pattern

All contexts follow this pattern:

Define context value type

interface MyContextValue {
  data: string;
  setData: (data: string) => void;
}

Create context with null default

const MyContext = createContext<MyContextValue | null>(null);

Create provider component

export function MyProvider({ children }: { children: ReactNode }) {
  const [data, setData] = useState('');
  
  const value = useMemo<MyContextValue>(
    () => ({ data, setData }),
    [data]
  );
  
  return (
    <MyContext.Provider value={value}>
      {children}
    </MyContext.Provider>
  );
}

Create typed hook

export function useMyContext(): MyContextValue {
  const ctx = useContext(MyContext);
  if (!ctx) {
    throw new Error('useMyContext must be used within MyProvider');
  }
  return ctx;
}

Performance Optimization

Split Contexts

Instead of one giant context:

interface AppContextValue {
  user: User;
  theme: Theme;
  files: File[];
  // ... 20 more fields
}

// Every component re-renders when ANY field changes!

Use multiple small contexts:

<UserProvider>
  <ThemeProvider>
    <FileProvider>
      {/* Components only re-render when their context changes */}
    </FileProvider>
  </ThemeProvider>
</UserProvider>

Memoize Context Value

Always memoize the context value:

const value = useMemo<MyContextValue>(
  () => ({ data, setData }),
  [data] // Only recompute when data changes
);

Without useMemo, context consumers re-render on every provider render.

Selector Pattern

For large contexts, expose selectors:

interface FileTreeContextValue {
  // Instead of exposing entire state:
  // state: { rootEntries, childrenByDir, ... }
  
  // Expose specific selectors:
  useRootEntries: () => FsEntry[];
  useChildrenByDir: () => Record<string, FsEntry[]>;
  useExpandedDirs: () => Set<string>;
}

// Components only subscribe to what they use
function FileList() {
  const rootEntries = useFileTreeContext().useRootEntries();
  // Only re-renders when rootEntries changes
}

Next Steps