Streamlet: Towards Compositional Reactivity

#engineering #architecture

What if building user interfaces could be as natural as describing how data flows? Not through layers of abstraction or complex state management, but through simple composition of streams that connect directly to the DOM.

The Vision

Modern frontend architectures often feel like we're fighting against the grain. Virtual DOMs diff entire trees. State management libraries wrap our data in ceremonies. Components re-render cascades of children. We've accepted complexity as the price of reactivity.

But what if we could build UIs where data flows through the DOM like water through a riverbed? Each value finding its own path, updating only what needs to change. No virtual DOM. No diffing. No unnecessary re-renders. Just data flowing directly to where it's needed.

This is the promise of compositional reactivity: building interfaces from streams that compose naturally at every layer, separated by concern.

Core Principles

Fine-grained reactivity: Individual values stream directly to DOM nodes. When a counter increments, only that text node updates. When a todo toggles, only that checkbox changes.

Composability: Streams combine without architectural friction. Merge user events into a unified intent stream. Compose state transformations through reducers. Derive computed values through selectors. Each layer builds naturally on the last.

Performance: The template renders once. After that, only data flows. No reconciliation algorithms. No component boundaries to cross. The shortest path from state to screen.

Transparency: Every change has a cause. Every effect has a source. The entire data flow is observable, debuggable, and predictable. Open the console and watch your application think.

Architecture Overview

The architecture follows a unidirectional data flow that should feel familiar, yet refreshingly direct:

  User โ”€โ”€โ”                                                        
         โ–ผ                                                        
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”     
    โ”‚ Events  โ”‚โ”€โ”€โ”€โ”€โ–บโ”‚ Intents โ”‚โ”€โ”€โ”€โ”€โ–บโ”‚  State   โ”‚โ”€โ”€โ”€โ”€โ–บโ”‚ View โ”‚     
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”˜     
         โ–ฒ                                               โ”‚        
         โ”‚                                               โ”‚        
         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        

But unlike traditional architectures, each arrow represents a living stream. Data doesn't pass through these layers; it flows through them continuously.

Patterns

The Intent Layer

User interactions become streams of intent. Not just events, but semantic actions that describe what the user wants to do:

// Primitive intents from DOM events
const click$ = new Subject();
const input$ = new Subject();

// Semantic intents with meaning
const addTodo$ = input$.pipe(
  filter(e => e.key === 'Enter'),
  map(e => ({ type: 'ADD_TODO', text: e.target.value }))
);

// Composed intents from multiple sources
const saveTodo$ = merge(
  blur$.pipe(map(e => ({ type: 'SAVE', id: e.target.id }))),
  submit$.pipe(map(e => ({ type: 'SAVE', id: e.detail.id })))
);

Intents compose naturally. Merge them, filter them, transform them. They're just streams.

The State Layer

State management becomes a composition problem. Instead of one monolithic reducer, we compose smaller, focused reducers:

// Reducer for todos array
const todosReducer = (todos = [], intent) => {
  switch (intent.type) {
    case 'ADD_TODO':
      return [...todos, {
        id: intent.id,
        text: intent.text,
        done: false
      }];
    case 'TOGGLE_TODO':
      return todos.map(todo =>
        todo.id === intent.id
          ? { ...todo, done: !todo.done }
          : todo
      );
    default:
      return todos;
  }
};

// Reducer for filter state
const filterReducer = (filter = 'all', intent) => {
  switch (intent.type) {
    case 'SET_FILTER':
      return intent.filter;
    default:
      return filter;
  }
};

// Compose reducers into root reducer
const reducer = (state, intent) => ({
  todos: todosReducer(state.todos, intent),
  filter: filterReducer(state.filter, intent)
});

// Create state stream
const state$ = intents$.pipe(
  scan(reducer, initialState),
  shareReplay(1)
);

Each reducer handles its own slice of state. They compose without coupling. Add a new feature? Add a new reducer. The architecture scales naturally.

The Selector Layer

Selectors derive specific values from state. They're composable, memoized streams:

// Basic selectors
const todos$ = state$.pipe(
  map(state => state.todos),
  distinctUntilChanged()
);

const filter$ = state$.pipe(
  map(state => state.filter),
  distinctUntilChanged()
);

// Composed selectors
const visibleTodos$ = combineLatest([todos$, filter$]).pipe(
  map(([todos, filter]) => {
    switch(filter) {
      case 'active': return todos.filter(t => !t.done);
      case 'completed': return todos.filter(t => t.done);
      default: return todos;
    }
  })
);

// Parameterized selectors
const todoById$ = (id) => todos$.pipe(
  map(todos => todos.find(t => t.id === id)),
  distinctUntilChanged()
);

Each selector only emits when its value actually changes. Subscribers downstream never see redundant updates.

The View Layer

This is where the magic happens. A custom directive bridges RxJS observables directly to lit-html templates:

const observe = directive(class extends AsyncDirective {
  render(observable) {
    if (this.observable !== observable) {
      this.unsubscribe?.();
      this.observable = observable;
      if (this.isConnected) {
        this.subscribe(observable);
      }
    }
    return noChange;
  }
  
  subscribe(observable) {
    this.unsubscribe = observable.subscribe(value => {
      this.setValue(value);
    });
  }
  
  disconnected() {
    this.unsubscribe?.();
  }
  
  reconnected() {
    this.subscribe(this.observable);
  }
});

With this directive, templates become declarative descriptions of data flow:

const TodoItem = (id) => html`
  <li class=${observe(todoById$(id).pipe(map(t => t.done ? 'done' : '')))}>
    <span>${observe(todoById$(id).pipe(map(t => t.text)))}</span>
    <button @click=${() => intents$.next({ type: 'TOGGLE_TODO', id })}>
      ${observe(todoById$(id).pipe(map(t => t.done ? 'โœ“' : 'โ—‹')))}
    </button>
  </li>
`;

The template renders once. After that, only values flow through the observe directives to update specific DOM nodes.

The Effect Layer

Side effects become explicit streams that run alongside the main data flow:

// Save to localStorage whenever todos change
todos$.pipe(
  debounceTime(1000),
  tap(todos => localStorage.setItem('todos', JSON.stringify(todos)))
).subscribe();

// Sync with backend
state$.pipe(
  debounceTime(2000),
  switchMap(state => 
    fetch('/api/state', {
      method: 'PUT',
      body: JSON.stringify(state)
    })
  ),
  retry(3)
).subscribe();

Effects compose just like everything else. Debounce them, retry them, cancel them. They're just streams.

Why This Matters

Traditional architectures optimize for developer experience at the cost of runtime performance. Virtual DOMs make updates easy to reason about but expensive to execute. Component models provide nice boundaries but create re-render cascades.

Compositional reactivity inverts this trade-off. By making data flow explicit and granular, we get both clarity and performance. The architecture mirrors how we think about UI: values flow from state to screen.

But perhaps more importantly, everything composes. There are no special cases, no framework magic, no hidden complexity. Just streams combining to create behavior.

Complete Todo Application

Here's a complete todo application demonstrating these patterns. Notice how each piece builds naturally on the previous ones:

<!DOCTYPE html>
<html>
<head>
  <title>Streamlet Todo</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      max-width: 600px;
      margin: 0 auto;
      padding: 1rem;
    }
    
    .add-todo {
      width: 100%;
      padding: 0.5rem;
      margin-bottom: 1rem;
    }
    
    .filters {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1rem;
    }
    
    .filter.active {
      font-weight: bold;
    }
    
    .todo-list {
      list-style: none;
      padding: 0;
    }
    
    .todo {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      padding: 0.5rem 0;
    }
    
    .todo.done {
      opacity: 0.5;
    }
    
    .todo.done .todo-text {
      text-decoration: line-through;
    }
    
    .todo-text {
      flex: 1;
    }
    
    .stats {
      margin-top: 1rem;
      padding: 0.5rem;
      background: #f5f5f5;
    }
  </style>
</head>
<body>
  <div id="app"></div>

  <script type="module">
    import { Subject, merge, combineLatest, scan, map, startWith, shareReplay, distinctUntilChanged, tap, debounceTime } from 'https://esm.sh/rxjs@7';
    import { html, render, noChange } from 'https://esm.sh/lit-html@3';
    import { directive, AsyncDirective } from 'https://esm.sh/lit-html@3/async-directive.js';

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // OBSERVE DIRECTIVE - Bridge between RxJS and lit-html
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    class ObserveDirective extends AsyncDirective {
      render(observable) {
        if (this.observable !== observable) {
          this.unsubscribe?.();
          this.observable = observable;
          if (this.isConnected) {
            this.subscribe(observable);
          }
        }
        return noChange;
      }
      
      subscribe(observable) {
        this.unsubscribe = observable.subscribe(value => {
          this.setValue(value);
        });
      }
      
      disconnected() {
        this.unsubscribe?.();
      }
      
      reconnected() {
        this.subscribe(this.observable);
      }
    }
    
    const observe = directive(ObserveDirective);

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // INTENT LAYER - User actions as streams
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    const addTodo$ = new Subject();
    const toggleTodo$ = new Subject();
    const deleteTodo$ = new Subject();
    const setFilter$ = new Subject();
    const clearCompleted$ = new Subject();

    const intents$ = merge(
      addTodo$.pipe(map(text => ({ type: 'ADD_TODO', text }))),
      toggleTodo$.pipe(map(id => ({ type: 'TOGGLE_TODO', id }))),
      deleteTodo$.pipe(map(id => ({ type: 'DELETE_TODO', id }))),
      setFilter$.pipe(map(filter => ({ type: 'SET_FILTER', filter }))),
      clearCompleted$.pipe(map(() => ({ type: 'CLEAR_COMPLETED' })))
    );

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // STATE LAYER - Composed reducers for modular state management
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    // Reducer for todos array
    const todosReducer = (todos = [], intent) => {
      switch (intent.type) {
        case 'ADD_TODO':
          return [...todos, {
            id: intent.id,
            text: intent.text,
            done: false
          }];
          
        case 'TOGGLE_TODO':
          return todos.map(todo =>
            todo.id === intent.id
              ? { ...todo, done: !todo.done }
              : todo
          );
          
        case 'DELETE_TODO':
          return todos.filter(todo => todo.id !== intent.id);
          
        case 'CLEAR_COMPLETED':
          return todos.filter(todo => !todo.done);
          
        default:
          return todos;
      }
    };

    // Reducer for nextId
    const nextIdReducer = (nextId = 1, intent) => {
      switch (intent.type) {
        case 'ADD_TODO':
          return nextId + 1;
        default:
          return nextId;
      }
    };

    // Reducer for filter
    const filterReducer = (filter = 'all', intent) => {
      switch (intent.type) {
        case 'SET_FILTER':
          return intent.filter;
        default:
          return filter;
      }
    };

    // Compose reducers into root reducer
    const reducer = (state, intent) => {
      // Handle ADD_TODO specially to pass the current nextId
      const enhancedIntent = intent.type === 'ADD_TODO' 
        ? { ...intent, id: state.nextId }
        : intent;
        
      return {
        todos: todosReducer(state.todos, enhancedIntent),
        nextId: nextIdReducer(state.nextId, intent),
        filter: filterReducer(state.filter, intent)
      };
    };

    const initialState = {
      todos: [
        { id: 1, text: 'Build a reactive todo app', done: true },
        { id: 2, text: 'Compose streams naturally', done: false },
        { id: 3, text: 'Achieve fine-grained updates', done: false }
      ],
      filter: 'all',
      nextId: 4
    };

    const state$ = intents$.pipe(
      scan(reducer, initialState),
      startWith(initialState),
      shareReplay(1)
    );

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // SELECTOR LAYER - Composable derivations
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    const todos$ = state$.pipe(
      map(state => state.todos),
      distinctUntilChanged()
    );

    const filter$ = state$.pipe(
      map(state => state.filter),
      distinctUntilChanged()
    );

    const visibleTodos$ = combineLatest([todos$, filter$]).pipe(
      map(([todos, filter]) => {
        switch (filter) {
          case 'active': return todos.filter(t => !t.done);
          case 'completed': return todos.filter(t => t.done);
          default: return todos;
        }
      })
    );

    const todoCount$ = todos$.pipe(
      map(todos => todos.filter(t => !t.done).length)
    );

    const completedCount$ = todos$.pipe(
      map(todos => todos.filter(t => t.done).length)
    );

    const hasCompleted$ = completedCount$.pipe(
      map(count => count > 0)
    );

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // VIEW LAYER - Declarative templates with fine-grained updates
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    const TodoItem = (todo) => html`
      <li class="todo ${todo.done ? 'done' : ''}">
        <input
          type="checkbox"
          .checked=${todo.done}
          @change=${() => toggleTodo$.next(todo.id)}
        />
        <span class="todo-text">${todo.text}</span>
        <button @click=${() => deleteTodo$.next(todo.id)}>Delete</button>
      </li>
    `;

    const TodoList = () => html`
      <ul class="todo-list">
        ${observe(visibleTodos$.pipe(
          map(todos => todos.map(todo => TodoItem(todo)))
        ))}
      </ul>
    `;

    const Filters = () => html`
      <div class="filters">
        <button
          class="filter ${observe(filter$.pipe(map(f => f === 'all' ? 'active' : '')))}"
          @click=${() => setFilter$.next('all')}
        >
          All
        </button>
        <button
          class="filter ${observe(filter$.pipe(map(f => f === 'active' ? 'active' : '')))}"
          @click=${() => setFilter$.next('active')}
        >
          Active
        </button>
        <button
          class="filter ${observe(filter$.pipe(map(f => f === 'completed' ? 'active' : '')))}"
          @click=${() => setFilter$.next('completed')}
        >
          Completed
        </button>
        ${observe(hasCompleted$.pipe(
          map(has => has ? html`
            <button @click=${() => clearCompleted$.next()}>
              Clear completed
            </button>
          ` : '')
        ))}
      </div>
    `;

    const Stats = () => html`
      <div class="stats">
        ${observe(todoCount$)} active, ${observe(completedCount$)} completed
      </div>
    `;

    const App = () => html`
      <h1>Streamlet Todo</h1>
      
      <input
        type="text"
        class="add-todo"
        placeholder="What needs to be done?"
        @keydown=${(e) => {
          if (e.key === 'Enter' && e.target.value.trim()) {
            addTodo$.next(e.target.value.trim());
            e.target.value = '';
          }
        }}
      />
      
      ${Filters()}
      ${TodoList()}
      ${Stats()}
    `;

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // RENDER - Single render, then data flows
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    render(App(), document.getElementById('app'));

    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
    // EFFECTS - Side effects as explicit streams
    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

    // Save to localStorage
    todos$.pipe(
      debounceTime(500),
      tap(todos => localStorage.setItem('streamlet-todos', JSON.stringify(todos)))
    ).subscribe();

    // Log all intents for debugging
    intents$.pipe(
      tap(intent => console.log('Intent:', intent))
    ).subscribe();

    // Log state changes
    state$.pipe(
      tap(state => console.log('State:', state))
    ).subscribe();

  </script>
</body>
</html>

Open in StackBlitz