12 KiB
JSON-Driven UI Architecture Guide
Overview
This application uses a declarative JSON-driven architecture that allows you to build entire user interfaces from configuration rather than code. Combined with atomic components (all under 150 LOC) and custom hooks for business logic, this creates a highly maintainable and rapidly prototype-able system.
Core Concepts
1. JSON Schema Definition
Define your entire UI using JSON schemas with:
- Data Sources: KV store, computed values, static data
- Components: Shadcn UI components with props and bindings
- Actions: CRUD operations, toasts, navigation
- Events: User interactions that trigger actions
2. Atomic Components
Small, focused, reusable components:
- Atoms (< 50 LOC): ActionButton, IconButton, DataList, LoadingSpinner
- Molecules (50-100 LOC): StatCard, EmptyState, SearchBar
- Organisms (100-150 LOC): DataTable, FormBuilder, Dashboard
3. Custom Hooks
Extract business logic into reusable hooks:
- Data Management: useCRUD, useSearch, useSort, useJSONData
- UI State: useDialog, useActionExecutor
- Forms: useForm, useFormField
Quick Start
Define a Page Schema
import { PageSchema } from '@/types/json-ui'
export const myPageSchema: PageSchema = {
id: 'my-page',
name: 'My Page',
layout: { type: 'single' },
// Data sources
dataSources: [
{
id: 'items',
type: 'kv', // Persisted to KV store
key: 'my-items',
defaultValue: []
},
{
id: 'stats',
type: 'computed', // Computed from other data
compute: (data) => ({
total: data.items?.length || 0
}),
dependencies: ['items']
}
],
// UI components
components: [
{
id: 'root',
type: 'div',
props: { className: 'p-6' },
children: [
{
id: 'title',
type: 'h1',
props: {
className: 'text-3xl font-bold',
children: 'My Page'
}
},
{
id: 'add-button',
type: 'Button',
props: { children: 'Add Item' },
events: [
{
event: 'click',
actions: [
{
id: 'create-item',
type: 'create',
target: 'items',
compute: (data) => ({
id: Date.now(),
name: 'New Item'
})
},
{
id: 'show-toast',
type: 'show-toast',
message: 'Item added!',
variant: 'success'
}
]
}
]
}
]
}
]
}
Use Custom Hooks
import { useCRUD, useSearch, useDialog } from '@/hooks'
function MyComponent() {
// CRUD operations with persistence
const { items, create, update, remove } = useCRUD({
key: 'my-items',
defaultValue: [],
persist: true
})
// Search functionality
const { query, setQuery, filtered } = useSearch({
items,
searchFields: ['name', 'description']
})
// Dialog state management
const dialog = useDialog()
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{filtered.map(item => (
<div key={item.id}>{item.name}</div>
))}
<button onClick={dialog.open}>Open</button>
<Dialog open={dialog.isOpen} onOpenChange={dialog.setOpen}>
{/* dialog content */}
</Dialog>
</div>
)
}
Build Atomic Components
// Atom: Single responsibility, no business logic
export function ActionButton({ icon, label, onClick }: Props) {
return (
<Button onClick={onClick}>
{icon}
{label && <span className="ml-2">{label}</span>}
</Button>
)
}
// Molecule: Composition of atoms
export function SearchBar({ value, onChange, onClear }: Props) {
return (
<div className="flex gap-2">
<Input value={value} onChange={onChange} />
<ActionButton
icon={<X />}
onClick={onClear}
variant="ghost"
/>
</div>
)
}
Data Source Types
KV (Persistent)
{
id: 'todos',
type: 'kv',
key: 'app-todos',
defaultValue: []
}
Data persists between sessions using the KV store.
Static (Session Only)
{
id: 'searchQuery',
type: 'static',
defaultValue: ''
}
Data lives only in memory, reset on page reload.
Computed (Derived)
{
id: 'stats',
type: 'computed',
compute: (data) => ({
total: data.todos.length,
completed: data.todos.filter(t => t.completed).length
}),
dependencies: ['todos']
}
Automatically recomputes when dependencies change.
Action Types
CRUD Actions
Create: Add new items
{
type: 'create',
target: 'todos',
compute: (data) => ({
id: Date.now(),
text: data.newTodo
})
}
Update: Modify existing data
{
type: 'update',
target: 'todos',
compute: (data) =>
data.todos.map(t =>
t.id === selectedId ? { ...t, completed: true } : t
)
}
Delete: Remove items
{
type: 'delete',
target: 'todos',
path: 'id',
value: todoId
}
UI Actions
Show Toast
{
type: 'show-toast',
message: 'Task completed!',
variant: 'success' // success | error | info | warning
}
Navigate
{
type: 'navigate',
path: '/dashboard'
}
Value Actions
Set Value
{
type: 'set-value',
target: 'searchQuery',
compute: (data, event) => event.target.value
}
Toggle Value
{
type: 'toggle-value',
target: 'showCompleted'
}
Increment/Decrement
{
type: 'increment',
target: 'counter',
value: 1
}
Component Bindings
Bind component props to data sources:
{
id: 'input',
type: 'Input',
bindings: {
value: {
source: 'searchQuery'
},
placeholder: {
source: 'settings',
path: 'inputPlaceholder'
}
}
}
With transformations:
{
bindings: {
children: {
source: 'count',
transform: (value) => `${value} items`
}
}
}
Event Handling
Simple Event
{
events: [
{
event: 'click',
actions: [{ type: 'show-toast', message: 'Clicked!' }]
}
]
}
Conditional Event
{
events: [
{
event: 'click',
condition: (data) => data.searchQuery.length > 0,
actions: [/* ... */]
}
]
}
Multiple Actions
{
events: [
{
event: 'click',
actions: [
{ type: 'create', target: 'items', /* ... */ },
{ type: 'set-value', target: 'input', value: '' },
{ type: 'show-toast', message: 'Added!' }
]
}
]
}
Available Custom Hooks
Data Hooks
useCRUD<T>
Complete CRUD operations with KV persistence
const { items, create, read, update, remove, clear } = useCRUD({
key: 'todos',
defaultValue: [],
persist: true
})
useSearch<T>
Multi-field search with filtering
const { query, setQuery, filtered, resultCount } = useSearch({
items: todos,
searchFields: ['text', 'tags'],
caseSensitive: false
})
useSort<T>
Multi-key sorting with direction toggle
const { sorted, field, direction, toggleSort } = useSort({
items: todos,
initialField: 'createdAt',
initialDirection: 'desc'
})
useJSONData
Flexible data management with optional persistence
const { value, setValue, updatePath, reset } = useJSONData({
key: 'user-prefs',
defaultValue: {},
persist: true
})
UI Hooks
useDialog
Dialog/modal state management
const dialog = useDialog(false)
// dialog.isOpen, dialog.open(), dialog.close(), dialog.toggle()
useActionExecutor
Execute JSON-defined actions
const { executeAction, executeActions } = useActionExecutor(context)
await executeAction({ type: 'create', target: 'items', /* ... */ })
Best Practices
1. Keep Components Small
- Atoms: < 50 LOC
- Molecules: 50-100 LOC
- Organisms: 100-150 LOC
- If larger, split into smaller pieces
2. Extract Logic to Hooks
// ❌ Bad: Logic in component
function TodoList() {
const [todos, setTodos] = useState([])
const addTodo = (text) => setTodos([...todos, { id: Date.now(), text }])
const removeTodo = (id) => setTodos(todos.filter(t => t.id !== id))
// ...
}
// ✅ Good: Logic in hook
function TodoList() {
const { items: todos, create, remove } = useCRUD({
key: 'todos',
defaultValue: []
})
// Component only handles UI
}
3. Use Computed Data Sources
// ❌ Bad: Computing in render
components: [{
type: 'div',
props: {
children: `${data.todos.filter(t => t.completed).length} completed`
}
}]
// ✅ Good: Computed data source
dataSources: [
{
id: 'stats',
type: 'computed',
compute: (data) => ({
completed: data.todos.filter(t => t.completed).length
})
}
],
components: [{
bindings: {
children: {
source: 'stats',
path: 'completed',
transform: (v) => `${v} completed`
}
}
}]
4. Chain Actions
// Multiple related actions in sequence
events: [{
event: 'click',
actions: [
{ type: 'create', /* ... */ }, // 1. Add item
{ type: 'set-value', /* ... */ }, // 2. Clear input
{ type: 'show-toast', /* ... */ } // 3. Show feedback
]
}]
5. Leverage Atomic Composition
// Build complex UIs from simple atoms
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
</CardHeader>
<CardContent>
<DataList
items={filtered}
renderItem={(item) => (
<div className="flex gap-2">
<ActionButton icon={<Edit />} onClick={() => edit(item)} />
<ActionButton icon={<Trash />} onClick={() => remove(item.id)} />
</div>
)}
/>
</CardContent>
</Card>
Example: Complete Todo App
See /src/schemas/todo-list.json for a full working example with:
- KV persistence
- Computed statistics
- CRUD operations
- Action chaining
- Conditional rendering
- Event handling
Migration Strategy
Phase 1: Extract Hooks
- Identify repeated logic patterns
- Create custom hooks
- Replace inline logic with hook calls
Phase 2: Break Down Components
- Identify large components (>150 LOC)
- Split into atoms, molecules, organisms
- Compose back together
Phase 3: Define JSON Schemas
- Convert simple pages to JSON first
- Test with PageRenderer
- Gradually migrate complex pages
Performance Tips
- Use
useMemofor expensive computations - Implement virtual scrolling for large lists
- Lazy load heavy components
- Debounce search and filter operations
- Use computed data sources instead of computing in render
Troubleshooting
Data not persisting?
- Check data source type is 'kv'
- Verify key is unique
- Ensure useKV is called unconditionally
Actions not executing?
- Check action type spelling
- Verify target matches data source id
- Ensure compute function returns correct type
Components not rendering?
- Check component type is registered
- Verify props match component API
- Check conditional bindings
Resources
- Type Definitions:
/src/types/json-ui.ts - JSON Schemas:
/src/schemas/*.json - Compute Functions:
/src/schemas/compute-functions.ts - Schema Loader:
/src/schemas/schema-loader.ts - Custom Hooks:
/src/hooks/data/and/src/hooks/ui/ - Atomic Components:
/src/components/atoms/ - Component Registry:
/src/lib/json-ui/component-registry.ts
Built with ❤️ using React, TypeScript, and Shadcn UI