Component communication

A Zoijs component is just a function that returns html. So "communication" between components is nothing exotic — it's the things functions already do: you pass arguments in, and you call callbacks out. There is no props system to learn, no context, no providers, no event bus.

This page shows five small patterns that cover almost everything, and the anti-patterns to avoid.

The golden rule: data flows down as arguments; events flow up as callbacks. A child never reaches up to change its parent's state directly.

1. Parent passes plain data down#

The simplest case: a component takes a plain object of data and renders it. Pass arguments when you call the function.

import { html } from "@zoijs/core";

function UserCard({ name, role }) {
  return html`
    <div class="card">
      <h3>${name}</h3>
      <p>${role}</p>
    </div>
  `;
}

// A parent uses it by calling it:
function Team() {
  return html`
    <div class="grid">
      ${UserCard({ name: "Ada Lovelace", role: "Engineer" })}
      ${UserCard({ name: "Alan Turing", role: "Admin" })}
    </div>
  `;
}

This is perfect for data that doesn't change after render. Remember setup runs once${name} is rendered a single time. If the data needs to update later, use a reader function (pattern 3).

2. Child sends events up with callbacks#

A child should never modify the parent's state. Instead, the parent passes a callback function, and the child calls it. The parent stays the owner of the state.

import { html, createState } from "@zoijs/core";

// Child: knows nothing about the parent's state — it just reports a click.
function CounterButton({ label, onCount }) {
  return html`<button onclick=${() => onCount(1)}>${label}</button>`;
}

// Parent: owns the total, decides what a click means.
function Tally() {
  const total = createState(0);
  return html`
    <p>Total: ${() => total.get()}</p>
    ${CounterButton({ label: "+1", onCount: (n) => total.set(total.get() + n) })}
  `;
}

The child's input is label, its output is onCount. It's reusable anywhere because it makes no assumptions about who's listening.

3. Pass reactive values as reader functions#

To pass data that changes over time, don't pass a snapshot — pass a reader function (() => …) and let the child wrap it in a binding. This is the same "one rule" as everywhere else in Zoijs.

function UserCard({ name, role }) {
  // name and role are now reader functions
  return html`
    <div class="card">
      <h3>${() => name()}</h3>
      <p>${() => role()}</p>
    </div>
  `;
}

function Profile() {
  const user = createState({ name: "Ada", role: "Engineer" });

  setTimeout(() => user.set({ name: "Ada", role: "Admin" }), 2000);

  return html`
    ${UserCard({ name: () => user.get().name, role: () => user.get().role })}
  `;
}

After two seconds the role updates in place — no re-render, no diffing. If you had passed user.get().role (a snapshot), it would be frozen at "Engineer".

Rule of thumb: pass a plain value for data that's fixed at render time; pass a () => value reader for data that updates.

4. Reusable inputs: the controlled pattern#

A reusable input owns no state of its own. The parent owns the value, and the input is told what to show (value) and how to report changes (onInput). This is the controlled component pattern.

function TextInput({ value, onInput, placeholder = "" }) {
  return html`
    <input
      placeholder=${placeholder}
      value=${() => value()}
      oninput=${(e) => onInput(e.target.value)} />
  `;
}

function NameForm() {
  const name = createState("");
  return html`
    ${TextInput({ value: () => name.get(), onInput: (v) => name.set(v) })}
    <p>Hello, ${() => name.get() || "stranger"}!</p>
  `;
}

value is a reader; oninput sends the new value up; the parent's state is the single source of truth. Binding value=${() => value()} is safe — the state updates synchronously inside oninput before the binding re-applies the same value, so the cursor never jumps. The same TextInput works in any form because it has clear inputs (value) and outputs (onInput).

5. Shared state through a plain module — only when needed#

When two distant components need the same state — and threading it through arguments would be awkward — put the state in a plain module and import it where it's needed. No store, no provider, no context: just an exported createState.

// current-user.js
import { createState } from "@zoijs/core";

export const currentUser = createState(null);
// header.js
import { html } from "@zoijs/core";
import { currentUser } from "./current-user.js";

export function Header() {
  return html`<header>Signed in as ${() => currentUser.get()?.name ?? "Guest"}</header>`;
}
// login.js — a far-away component updates the same state
import { currentUser } from "./current-user.js";
currentUser.set({ name: "Ada Lovelace" });

Both components read and write the same reactive value, and the UI stays in sync — because it's the same createState. Reach for this only when multiple components genuinely share state. For a parent and its own child, arguments and callbacks (patterns 1–4) are simpler and keep components decoupled.

A fuller example: TodoItem with callbacks#

Putting it together — a list parent owns the array; each TodoItem reports toggle and delete events up via callbacks.

import { html, mount, createState, each } from "@zoijs/core";

function TodoItem({ todo, onToggle, onDelete }) {
  return html`
    <li>
      <input type="checkbox" checked=${() => todo.done} onchange=${() => onToggle(todo.id)} />
      <span class=${() => (todo.done ? "done" : "")}>${() => todo.text}</span>
      <button onclick=${() => onDelete(todo.id)}>Delete</button>
    </li>
  `;
}

function TodoList() {
  const todos = createState([
    { id: 1, text: "Learn Zoijs", done: false },
    { id: 2, text: "Build something", done: false },
  ]);

  const toggle = (id) =>
    todos.set(todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  const remove = (id) => todos.set(todos.get().filter((t) => t.id !== id));

  return html`
    <ul>
      ${each(
        () => todos.get(),
        (t) => t.id,
        (t) => TodoItem({ todo: t, onToggle: toggle, onDelete: remove })
      )}
    </ul>
  `;
}

mount(TodoList, "#app");

TodoItem doesn't know how toggling or deleting works — it just calls onToggle and onDelete. TodoList owns the data and decides what those mean. Either component can be tested or reused on its own.

Communication rules#

  • Data flows down, events flow up. Pass data as arguments; pass callbacks for the child to report back.
  • The owner of the state performs the mutation. A child requests a change by calling a callback; it never writes the parent's state itself.
  • Pass readers (() => value) for data that changes, plain values for data that doesn't.
  • Controlled inputs take value + onInput. The parent owns the value.
  • Prefer small components with clear inputs and outputs. If you can name what goes in and what comes out, it'll be easy to reuse and test.
  • Use callbacks, not an event bus. A direct callback is explicit and traceable; a global emitter hides who's talking to whom.
  • Use a shared module only when multiple components need the same state. Local relationships should stay local.

Anti-patterns to avoid#

  • ❌ Mutating parent state from a child. Importing or capturing the parent's createState inside the child and calling .set() on it couples them tightly. Pass a callback instead — the parent stays in control.
  • ❌ Putting everything in global modules. A single app-wide "store" module makes every component depend on shared mutable state and hard to reuse. Keep state as local as possible; promote to a module only when sharing demands it.
  • ❌ Building an event bus / emitter. bus.emit("user:login", …) and bus.on(...) scatter cause and effect across the app. Callbacks passed as arguments keep the data flow visible in one place.
  • ❌ Passing snapshots when you need live data. UserCard({ name: user.get().name }) freezes the value at render time. Pass name: () => user.get().name so it stays reactive.
  • ❌ Reaching into the DOM to communicate. Don't have one component querySelector another's nodes. Share a reader/callback (or a module value) so the relationship is explicit.

Why this stays simple#

There's no props object to validate, no context to thread, no provider to wrap your app, no emit/inject, no higher-order components. A component is a function: arguments in, callbacks out, and createState in a module on the rare occasion two parts of the app share something. That's the whole model.

See Core Concepts for the one rule that powers all of this, and State for how reactive values work.