dwaan-frameworkless-crud Svelte Themes

Dwaan Frameworkless Crud

CBA: A task management app built completely from scratch, without using any UI frameworks like React, Vue, or Svelte. Everything—from the virtual DOM and reactivity system to routing and storage—is implemented manually in plain TypeScript.

Dwaan

/dwaːn/ — Frisian for "to do, to act, to carry out"

A task management application built without any UI framework. No React, no Vue, no Svelte. The entire rendering stack — virtual DOM, signals, reactivity, routing, storage — is written from scratch in vanilla TypeScript.


Table of Contents

  1. Why frameworkless?
  2. Getting started
  3. Architecture overview
  4. The VDOM engine
  5. The Flow signal library
  6. State management
  7. Storage layer
  8. Layout system
  9. Vendor modules
  10. How to extend
  11. Current state & roadmap

Why frameworkless?

This project is deliberately built without a UI framework for two reasons:

  1. Learning — understanding what React, Solid, and Vue actually do by building equivalent primitives.
  2. Control — zero runtime overhead, no virtual DOM surprises, no upgrade churn, full ownership of every byte of rendering logic.

The result is a stack that borrows the best ideas from modern frameworks (JSX, signals, fine-grained reactivity) while staying 100% vanilla TypeScript with no runtime dependencies beyond Vite for bundling.


Getting started

# Install Bun (https://bun.sh) and Rust (https://rustup.rs) first
./scripts/prerequisites.sh   # automated setup, or install manually per README

bun install
bun run dev       # web dev server (Vite)
bun run tauri dev # desktop app (requires Rust + Tauri CLI)

Other useful commands:

bun run build        # typecheck + production build
bun run typecheck    # tsc --noEmit
bun run check        # Biome lint
bun run fix          # Biome lint + auto-fix
bun test             # run tests
bun test <file>      # run a single test file

The apps/ directory contains two sub-projects:

bun run dev:docs        # documentation site
bun run dev:playground  # component playground

Architecture overview

src/
├── app/          boot and singleton state store
├── domain/       pure types: Task, Project, Pane, Layout, tree helpers
├── mutations/    pure functions that return new state (no side effects)
├── repos/        persistence: load from storage → state, save state → storage
├── helpers/      stateless utilities (filter, search, drag, keybindings)
├── ui/           TSX components
│   └── styles/   modular CSS (base, layout, components, motion, dnd)
├── types/        shared primitives (ID, Time) and global.d.ts
└── vendor/       internal libraries (see below)
    ├── jsx/      custom VDOM and renderer
    ├── flow/     signal library (val, computed, effects)
    ├── database/ IndexedDB wrapper
    ├── storage/  storage adapter interface
    ├── motion/   View Transitions API integration
    ├── key/      keyboard shortcut manager
    ├── router/   URL search-param state sync
    └── dnd/      drag-and-drop utilities

The application is a uni-directional data flow pipeline:

user event → mutation → setState → subscribers → re-render
                                       └── repo.save (debounced)
                                       └── syncUrl

The VDOM engine

Location: src/vendor/jsx

A minimal virtual DOM with diffing and patching. The TypeScript compiler is configured to use this as the JSX transform (jsxImportSource: "@/vendor/jsx"), so .tsx files work naturally.

How it works

h() builds a VNode tree. render(vnode, container) mounts it. On re-render it calls _patch(), which walks both old and new VNode trees and makes targeted DOM mutations.

type VNode = {
    type: string | Component | typeof Fragment
    props: Props
    children: VNode[]
    key?: string | number
    dom?: Node
}

Reactive signal props

Signal (Val<T>) values can be passed as props or children. The renderer detects them via isVal() and subscribes directly — bypassing the full VDOM diff for those attributes. This is fine-grained reactivity at the attribute level.

const label = val('hello')
<button class={label}>click</button>  // updates only the class attr

Reconciliation

_reconcileChildren does a positional diff (index-based, not key-based for most lists). Key-based reconciliation is supported when key props are provided.

What it does NOT do

  • No useEffect, useRef, or lifecycle hooks — components are plain functions called once per render
  • No batching — setState triggers synchronous re-render (wrapped in View Transitions)
  • Signal subscriptions on DOM nodes are not cleaned up on removal (tracked as a known limitation, see _dispose on nodes)

The Flow signal library

Location: src/vendor/flow

A push-based reactive primitive inspired by SolidJS signals. The core class is Signal<T> with dependency tracking via a currentSubscriber global.

Primitives

import { val } from '@/vendor/flow'

const count = val(0)       // Signal<number>
count()                    // read → 0
count(5)                   // write → notifies subscribers

const double = count.to(n => n * 2)   // Computed (read-only derived signal)
const stop = count.do(n => log(n))    // Effect (runs immediately, returns disposer)

const label = count
    .if(n => n < 0).then('negative')
    .elif(n => n === 0).then('zero')
    .else('positive')                 // ConditionalChain → Val<string>

How tracking works

Computed and Effect set currentSubscriber to themselves before running their function. Any signal.get() call during that run adds currentSubscriber to the signal's subscriber set. When the signal's value changes it calls subscriber.update() on each subscriber.

Control flow components

Fine-grained list and conditional rendering that bypasses parent re-renders:

import { For, Show } from '@/vendor/flow'

// List — only diffs items, not the parent
<For each={items$}>
    {(item, i) => <li>{item.name}</li>}
</For>

// Conditional
<Show when={isLoaded$} fallback={<Spinner />}>
    <Content />
</Show>

Both components render a display: contents wrapper div and subscribe to the signal directly, patching their own children without involvement from the parent component's render.

Known limitations

  • Effect.dispose() does not remove the effect from its source signals (bidirectional linking not yet implemented — marked as MVP)
  • <For> is index-based; reordering items with stable keys will cause full re-renders of moved items
  • <Switch>/<Match> implementation is partial

State management

Location: src/app/app-state.ts

A singleton pub/sub store. No external library.

getState()                 // read current State
setState(prev => next)     // immutable update, notifies all subscribers
subscribe(fn)              // returns unsubscribe function

State is a flat record-based structure intentionally designed for O(1) lookups:

type State = {
    workspaces: Workspace[]
    projects: Record<ID, Project>
    tasks: Record<ID, Task>
    panes: Record<ID, Pane>
    layout: Layout | null
    activeWorkspace: ID | null
    activePane: ID | null
    searchQuery?: string
}

All state mutations live in src/mutations/. Each mutation is a pure function: takes current state, returns new state. UI components call setState with these functions.

// Example mutation usage
setState(prev => createTask(prev, { title: 'New task', projectId }))

Storage layer

Location: src/vendor/storage, src/vendor/database

An adapter pattern decouples the application from any specific storage backend.

type StorageAdapter = {
    get<T>(key: string): Promise<T | undefined>
    set<T>(key: string, value: T): Promise<void>
    delete(key: string): Promise<void>
    clear(): Promise<void>
}

Two adapters exist:

Adapter File Used when
IndexedDB src/vendor/storage/indexeddb.ts Web browser (default)
Tauri FS src/vendor/storage/tauri.ts Desktop app

The adapter is set once during boot in app-init.ts:

setStorageAdapter(createIndexedDBAdapter())

All application code reads and writes through the storage facade — never the adapter directly:

import { storage } from '@/vendor/storage'
await storage.set('key', value)
await storage.get<MyType>('key')

The database layer (src/vendor/database) owns the raw IndexedDB connection, store creation, and versioning. Currently five object stores: workspaces, projects, tasks, layout, storage.


Layout system

Location: src/domain/layout.ts, src/mutations/layout.ts

The pane layout is a persistent binary tree — the same model used by terminal multiplexers like tmux.

type LayoutNode =
    | { kind: 'pane'; paneId: PaneId; size: number }
    | { kind: 'split'; dir: 'h' | 'v'; a: LayoutNode; b: LayoutNode }

type Layout = { root: LayoutNode; focus: PaneId }

Operations (layoutSplit, layoutClose, layoutFocus, layoutResize) are pure functions on this tree — no mutation. The result is fed back into setState.

Keyboard shortcuts:

Key Action
Alt+H Split pane horizontally
Alt+V Split pane vertically
Alt+W Close focused pane
` Cycle through panes
Cmd/Ctrl+K Focus search

Vendor modules

Module Purpose
vendor/jsx VDOM, reconciler, h(), render(), Fragment
vendor/flow Signals (val), computed, effects, <For>, <Show>
vendor/database Raw IndexedDB operations (dbGet, dbSet, dbDelete)
vendor/storage Adapter interface + storage facade
vendor/motion View Transitions API wrapper (vt(fn)) with configurable speed/style
vendor/key Chainable keyboard bindings: Key.bind('Alt+h').preventDefault().to(fn)
vendor/router URL search-param sync (getUrlState, setUrlState, onPopState)
vendor/dnd Drag-and-drop helpers for task reordering

None of these are published packages — they live in the repo and can be modified freely.


How to extend

Adding a new domain type

  1. Add the type to src/domain/.
  2. Add it to State in src/domain/state.ts and initialise it in createInitialState in src/app/app-state.ts.
  3. Write mutations in src/mutations/.
  4. Write a repo for persistence in src/repos/ (load on boot, save on change).
  5. Load the repo in app-init.ts.

Adding a new mutation

Mutations are pure functions in src/mutations/. They take state and parameters, return new state. No side effects inside a mutation.

// src/mutations/my-mutation.ts
import type { State } from '@/domain/state'

export function doSomething(state: State, param: string): State {
    return {
        ...state,
        // ...changes
    }
}

// Usage in a component:
setState(prev => doSomething(prev, value))

Adding a new UI component

Components are functions that take props and return VNode. They are plain functions — no class, no lifecycle hooks.

// src/ui/MyComponent.tsx
import type { VNode } from '@/vendor/jsx'

type Props = { label: string }

export function MyComponent({ label }: Props): VNode {
    return <div class="my-component">{label}</div>
}

Use <For> and <Show> from @/vendor/flow when rendering signals. Use plain map/ternary for static data.

Adding a new keyboard shortcut

Add bindings in src/app/app-init.ts bindKeyboard() or inside a component's ref callback:

import { Key, isEditing } from '@/vendor/key'

Key.bind('Ctrl+d')
    .preventDefault()
    .to((e) => {
        if (isEditing(e.target)) return
        // handle
    })

Swapping the storage backend

Implement StorageAdapter, call setStorageAdapter() before loadPersistedState() in app-init.ts:

import { setStorageAdapter } from '@/vendor/storage'
import { createTauriAdapter } from '@/vendor/storage/tauri'

setStorageAdapter(createTauriAdapter())

Adding a new route/URL param

Extend UrlState in src/vendor/router/index.ts, read it in getUrlState, write it in setUrlState. Wire into applyUrlState and syncUrlFromState in app-init.ts.


Current state & roadmap

What works

  • Multi-pane layout with horizontal/vertical splits, resizing, focus cycling
  • Hierarchical tasks (unlimited depth) with expand/collapse
  • Drag-and-drop task reordering within and across parents
  • Projects with per-project task lists
  • Task fields: title, body, status (backlog/active/done), priority, due date, category
  • Sidebar with project list and search/filter
  • Full keyboard navigation
  • IndexedDB persistence with URL-based deep linking
  • View Transitions for smooth DOM updates

In progress / known gaps

  • Effect disposalEffect.dispose() is a no-op; signal subscriptions on removed DOM nodes leak (low impact for now, marked as TODO in vendor/flow/index.ts)
  • <Switch>/<Match> — implementation in vendor/flow/components.tsx is a stub; use <Show> chains instead
  • <For> keying — currently index-based; stable-key reconciliation not yet implemented
  • Tauri integrationsrc/vendor/storage/tauri.ts exists but the desktop build does not yet select it automatically; Tauri commands are not wired
  • WorkspacesWorkspace type exists in state but no UI or mutations for creating/switching workspaces
  • Task 2.0 UI — current TaskTree component does not use Flow signals internally; re-render granularity is at the full-app level
  • apps/docs and apps/playground — scaffolded but largely empty

Planned

  • Tauri desktop build with native FS storage
  • Fine-grained signal adoption in TaskTree and ProjectView using <For>
  • Workspace switching UI
  • Due-date reminders and calendar view
  • Platform-specific builds (build:$PLATFORM)

MIT License — Remco Stoeten

Top categories

Loading Svelte Themes