@node-tree/state
v0.0.1
Published
Reactive state management for virtual file systems
Readme
@node-tree/state
Reactive state management for virtual file systems. This package provides a complete tree state solution that mirrors any VFS implementation from @firesystem/core, with built-in support for navigation, selection, and expansion states.
Features
- 🎯 VFS Mirroring - Automatically syncs with any firesystem implementation
- 🚀 Framework Agnostic - Works with Zustand, MobX, or vanilla JS
- 📁 Tree Operations - Navigation, selection, expansion with keyboard-friendly APIs
- 👀 Real-time Sync - Auto-updates when files change via watch system
- 🔍 Smart Queries - Efficient getters for visible nodes, children, parents
- ✅ Fully Typed - Complete TypeScript support
- 🧪 Well Tested - Comprehensive unit and integration tests
Installation
npm install @node-tree/state @firesystem/core
# or
pnpm add @node-tree/state @firesystem/core
# or
yarn add @node-tree/state @firesystem/coreChoose your preferred state management library:
# For Zustand (recommended)
npm install zustand
# For MobX
npm install mobx mobx-react-liteQuick Start
With Zustand (Recommended)
import { createZustandTreeStore } from "@node-tree/state";
import { MemoryFileSystem } from "@firesystem/memory";
// Create store with optional file system
const fs = new MemoryFileSystem();
const useTreeStore = createZustandTreeStore(fs);
// Use in React component
function FileExplorer() {
const {
visibleNodes,
cursor,
expandNode,
collapseNode,
moveCursorDown,
moveCursorUp
} = useTreeStore();
const nodes = useTreeStore(state => state.getVisibleNodes());
return (
<div>
{nodes.map(node => (
<div
key={node.id}
style={{ paddingLeft: node.level * 20 }}
className={cursor === node.id ? 'selected' : ''}
>
{node.type === 'directory' && (
<button onClick={() => toggleNode(node.id)}>
{isExpanded(node.id) ? '▼' : '▶'}
</button>
)}
{node.name}
</div>
))}
</div>
);
}With MobX
import { createMobXTreeStore } from "@node-tree/state";
import { observer } from "mobx-react-lite";
const treeStore = createMobXTreeStore(fs);
const FileExplorer = observer(() => {
const nodes = treeStore.getVisibleNodes();
return (
<div>
{nodes.map(node => (
<div key={node.id}>
{node.name}
</div>
))}
</div>
);
});Vanilla JavaScript
import { TreeStateCore } from "@node-tree/state";
const treeState = new TreeStateCore();
// Load from file entries
treeState.loadFromFs([
{ path: "/src", name: "src", type: "directory" },
{ path: "/src/index.ts", name: "index.ts", type: "file" }
]);
// Navigate
treeState.moveCursorDown();
treeState.expandNode("src");
// Query
const visibleNodes = treeState.getVisibleNodes();Core Concepts
Tree Node Structure
interface TreeNode {
id: string; // Unique identifier
path: string; // Full path
name: string; // File/directory name
type: "file" | "directory";
level: number; // Depth in tree
parentId?: string; // Parent node ID
expanded?: boolean; // For directories
// ... other FileEntry properties
}Navigation Actions
// Cursor movement
store.moveCursorUp();
store.moveCursorDown();
store.moveCursorToFirst();
store.moveCursorToLast();
store.setCursor(nodeId);
// Expansion
store.expandNode(nodeId);
store.collapseNode(nodeId);
store.toggleNode(nodeId);
store.expandAll();
store.collapseAll();
store.expandPath("/src/components"); // Expands all parents
// Selection
store.selectNode(nodeId);
store.deselectNode(nodeId);
store.toggleSelection(nodeId);
store.selectAll();
store.clearSelection();
store.selectRange(fromId, toId); // Select visible nodes in rangeQueries
// Get nodes
const node = store.getNode(nodeId);
const node = store.getNodeByPath("/src/index.ts");
const children = store.getChildren(nodeId);
const parent = store.getParent(nodeId);
const siblings = store.getSiblings(nodeId);
// Get collections
const visible = store.getVisibleNodes();
const selected = store.getSelectedNodes();
const cursor = store.getCursorNode();
// Navigation helpers
const next = store.getNextVisible(nodeId);
const prev = store.getPreviousVisible(nodeId);
// State checks
const expanded = store.isExpanded(nodeId);
const selected = store.isSelected(nodeId);
const visible = store.isVisible(nodeId);
const hasKids = store.hasChildren(nodeId);File System Integration
The state automatically syncs with the file system when provided:
const store = createZustandTreeStore(fs);
// Watches all changes
fs.writeFile("/new-file.txt", "content");
// State auto-updates
fs.mkdir("/new-folder");
// State auto-updates
fs.deleteFile("/old-file.txt");
// State auto-updates
// Manual sync for specific events
store.syncWithFs({
type: "created",
path: "/manual-file.txt"
});
// Load from entries
const entries = await fs.readDir("/");
store.loadFromFs(entries);Advanced Usage
Custom Selectors (Zustand)
import { treeSelectors } from "@node-tree/state";
// Use predefined selectors
const visibleNodes = useTreeStore(treeSelectors.visibleNodes);
const cursorNode = useTreeStore(treeSelectors.cursorNode);
const children = useTreeStore(treeSelectors.childrenOf("src"));
// Create custom selectors
const fileCount = useTreeStore(state =>
Array.from(state.nodes.values()).filter(n => n.type === "file").length
);Keyboard Navigation Example
function useKeyboardNavigation(store) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowUp":
e.preventDefault();
store.moveCursorUp();
break;
case "ArrowDown":
e.preventDefault();
store.moveCursorDown();
break;
case "ArrowRight":
const cursor = store.getCursorNode();
if (cursor?.type === "directory") {
store.expandNode(cursor.id);
}
break;
case "ArrowLeft":
const current = store.getCursorNode();
if (current?.type === "directory" && store.isExpanded(current.id)) {
store.collapseNode(current.id);
} else if (current?.parentId) {
store.setCursor(current.parentId);
}
break;
case " ":
e.preventDefault();
const node = store.getCursorNode();
if (node) store.toggleSelection(node.id);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [store]);
}Performance Tips
- Use selectors for computed values instead of filtering in components
- Batch operations when making multiple state changes
- Virtualize long lists of visible nodes (use
@tanstack/react-virtualor similar) - Debounce rapid file system changes if needed
- Memoize expensive computations in React components
- Use
shallowequality in Zustand selectors for better performance
API Reference
TreeStore Interface
interface TreeStore {
// State
nodes: Map<string, TreeNode>;
rootIds: string[];
cursor?: string;
selectedIds: Set<string>;
expandedIds: Set<string>;
visibleIds: string[];
// All methods listed above...
}Store Creation Options
// Zustand
const store = createZustandTreeStore(fs?: IFileSystem);
// MobX
const store = createMobXTreeStore(fs?: IFileSystem);
// Vanilla (no reactivity)
const store = new TreeStateCore();React Integration Examples
Creating a File Explorer Component
// FileExplorer.tsx
import { useTreeStore } from './store';
import { useKeyboardNavigation } from './hooks';
export function FileExplorer() {
const visibleNodes = useTreeStore(state => state.getVisibleNodes());
const cursor = useTreeStore(state => state.cursor);
const { expandNode, collapseNode, setCursor } = useTreeStore();
useKeyboardNavigation();
return (
<div className="file-explorer">
{visibleNodes.map(node => (
<FileNode
key={node.id}
node={node}
isCursor={cursor === node.id}
onToggle={() => node.type === 'directory' && toggleNode(node.id)}
onClick={() => setCursor(node.id)}
/>
))}
</div>
);
}Using with Zustand DevTools
import { devtools } from 'zustand/middleware';
const useTreeStore = create<TreeStore>()(
devtools(
subscribeWithSelector((set, get) => ({
// ... your store
})),
{ name: 'tree-store' }
)
);Common Patterns
Lazy Loading Directories
// Only load directory contents when expanded
const handleExpand = async (nodeId: string) => {
const node = store.getNode(nodeId);
if (node?.type === 'directory' && !node.children?.length) {
const children = await fs.readDir(node.path);
// Update store with new children
}
store.expandNode(nodeId);
};Search Implementation
// Add search functionality
const searchNodes = (query: string) => {
const results: TreeNode[] = [];
for (const node of store.nodes.values()) {
if (node.name.toLowerCase().includes(query.toLowerCase())) {
results.push(node);
// Expand parent path to show result
store.expandPath(node.path);
}
}
return results;
};Troubleshooting
Issue: State not updating in React component
Solution: Make sure you're using the store correctly:
// ❌ Wrong - not reactive
const nodes = store.getVisibleNodes();
// ✅ Correct - reactive subscription
const nodes = useTreeStore(state => state.getVisibleNodes());Issue: Too many re-renders
Solution: Use selectors to minimize updates:
// ❌ Causes re-render on any state change
const state = useTreeStore();
// ✅ Only re-renders when visible nodes change
const visibleNodes = useTreeStore(state => state.getVisibleNodes());Issue: File system changes not reflecting
Solution: Ensure filesystem has watch support:
// Check if watch is working
const watcher = fs.watch('**', (event) => {
console.log('File changed:', event);
});Issue: Performance with large directories
Solution: Implement virtualization:
import { useVirtualizer } from '@tanstack/react-virtual';
// Only render visible items
const virtualizer = useVirtualizer({
count: visibleNodes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 24,
});Contributing
See ARCHITECTURE.md for detailed technical documentation.
License
MIT
