[]previous and next page

2. Write the first component

Pass props to a component, keep a todo in a store and update one checkbox and one class when it changes.

For agentsUpdated

Goal: a TodoItem component that shows one todo, and an App that owns the todo's state and toggles it. See State with useStore for the full state API.

The data#

Start with the shape of a todo. It is plain data; the framework does not care how you model it.

src/types.tsts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

A component that receives props#

A component is a function of one argument, props. Each property of props is a getter, so reading props.done inside JSX re-reads whatever the parent passed. That is how a child updates without being called again.

src/TodoItem.tsxtsx
export interface TodoItemProps {
  title: string;
  done: boolean;
  onToggle: () => void;
}

export function TodoItem(props: TodoItemProps) {
  return (
    <li classList={{ done: props.done }}>
      <label>
        <input type="checkbox" checked={props.done} onChange={() => props.onToggle()} />
        {props.title}
      </label>
    </li>
  );
}

Three things to notice:

  • classList={{ done: props.done }} toggles the class done whenever props.done changes. class="..." accepts a string, array or object too.
  • checked={props.done} is a binding: the compiler wraps the expression so that only this attribute is rewritten when props.done changes.
  • onChange is an event prop. Handlers are bound once; ShadowJS delegates common events (click, input, change, keydown ...) to the document.

The function body runs once, when the component is created. After that only the three bindings above (the class, the checked property and the title text) are kept up to date.

State with useStore#

App owns the todo. useStore with a plain object creates a deep store: reading todo.done subscribes to exactly that property, and the setter changes exactly what you name.

src/App.tsxtsx
import { useStore } from "@shadow-js/core";
import { TodoItem } from "./TodoItem";
import type { Todo } from "./types";

export function App() {
  const [todo, setTodo] = useStore<Todo>({ id: 1, title: "Learn ShadowJS", done: false });

  return (
    <main>
      <h1>Todos</h1>
      <ul>
        <TodoItem
          title={todo.title}
          done={todo.done}
          onToggle={() => setTodo("done", (done) => !done)}
        />
      </ul>
    </main>
  );
}

useStore has two shapes, decided by what you pass:

Call Returns Read Write
useStore(0) [read, write] count() setCount(5), setCount((n) => n + 1)
useStore({ ... }) or useStore([ ... ]) [store, setStore] todo.done (property access) setTodo("done", true), setTodo((draft) => { ... })

A value that can be null or change type (Todo | null) is a single value too: useStore<Todo | null>(null) returns [read, write], you replace it as a whole, and plain data assigned to it is still read fine-grained.

What you should see#

A “Todos” heading and one unchecked “Learn ShadowJS” item appear. Click the checkbox: the item becomes checked and gets the done class. Click again to clear it. The store updates both bindings without calling either component function again.

The last assertion shows that the same li element is still in the document: nothing was re-created, the class was changed in place.

Next: 3. Render a list.