sobria
ページの一覧

リアクティビティ

要素の状態は Signal で保持します。Signal の値が変化すると、sobria はその値を読み取っている箇所だけを更新します。要素の関数を実行し直すことはありません。

signal

signal は、読み取りの関数と書き込みの関数の組を返します。値は count() のように関数を呼び出して読みます。書き込みの関数には、新しい値か、直前の値から新しい値を返す関数を渡します。

// src/elements/counter.tsx
import { memo, signal } from "sobria";

export default function Counter() {
  const [count, setCount] = signal(0);
  const doubled = memo(() => count() * 2);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      {count()} の 2 倍は {doubled()}
    </button>
  );
}

書き込んだ値は、すぐには読み取りに反映されません。例えば、setCount(1) の直後に count() を読むと、書き込む前の値を返します。書き込みはまとめて反映され、イベントハンドラーの処理が終わった後には、count() が新しい値を返します。書き込んだ値をその後の処理でも使用するときは、変数に保持します。

// src/elements/like-button.tsx
const like = () => {
  const next = count() + 1;
  setCount(next);
  props.onLike?.(next);
};

memo

memo は、ほかの Signal や memo から計算する値を作成します。読み取った値が変化したときだけ計算し直します。

memo の関数は Promise か async iterable を返してもかまいません。この章では、Promise か async iterable を返す memo を非同期の memo と呼びます。memo の関数が async iterable を返すか、async iterable に解決する Promise を返すと、memo の値は、iterable が yield するたびにその値に置換されます(サーバー関数)。サーバーのデータを取得するときは、memo の中で サーバー関数 を呼び出します。

// src/elements/user-name.tsx
import { Loading, memo } from "sobria";

import { getUser } from "../lib/users.ts";

export default function UserName(props: { userId: number }) {
  const user = memo(() => getUser(props.userId));
  return (
    <Loading fallback={<p>読み込み中</p>}>
      <p>{user().name}</p>
    </Loading>
  );
}

getUser は、ユーザーを返すサーバー関数です。props.userId が変化すると、memo は新しいユーザーを取得します。

effect

effect(read, apply) は、read が読み取った値が変化するたびに、read の戻り値を apply に渡して呼びます。DOM の外への書き込みや、タイマーの設定のように、表示の更新以外の処理に使用します。

// src/elements/search-box.tsx
import { effect, signal } from "sobria";

export default function SearchBox() {
  const [query, setQuery] = signal("");
  effect(
    () => query(),
    (value) => {
      const timer = setTimeout(() => console.log("検索", value), 300);
      return () => clearTimeout(timer);
    },
  );
  return <input value={query()} onInput={(e) => setQuery(e.currentTarget.value)} />;
}
  • apply の中で読み取った値は追跡しません。apply を呼び直すきっかけになるのは、read が読み取った値だけです
  • apply の中で signal や memo を読むときは、untrack(() => value()) のように untrack で包みます。包まずに読むと、開発中にコンソールに STRICT_READ_UNTRACKED の警告が表示されます。untrack は sobria から import します
  • apply が関数を返すと、sobria はその関数を、次に apply を呼ぶ前と、要素を破棄するときに呼びます
  • read が非同期の memo を読むときは、memo が新しい値に解決してから apply を呼びます

external

external(subscribe, getSnapshot, getServerSnapshot?) は、URL のクエリやメディアクエリのように、sobria の外にある状態を読み取る関数を返します。subscribe は、状態が変化したときに呼ぶ関数を受け取り、購読を解除する関数を返します。

次の例は、URL のクエリの sort を読み書きする要素です。

// src/elements/sort-select.tsx
import { external } from "sobria";

const listeners = new Set<() => void>();

function subscribe(notify: () => void) {
  listeners.add(notify);
  addEventListener("popstate", notify);
  return () => {
    listeners.delete(notify);
    removeEventListener("popstate", notify);
  };
}

function setSort(value: string) {
  const url = new URL(location.href);
  url.searchParams.set("sort", value);
  history.pushState(null, "", url);
  for (const notify of listeners) notify();
}

const sortOf = (search: string) => new URLSearchParams(search).get("sort") ?? "new";

export default function SortSelect(props: { search: string }) {
  const sort = external(
    subscribe,
    () => sortOf(location.search),
    () => sortOf(props.search),
  );
  return (
    <select value={sort()} onChange={(e) => setSort(e.currentTarget.value)}>
      <option value="new">新しい順</option>
      <option value="popular">人気順</option>
    </select>
  );
}
// src/routes/posts/page.tsx
import type { PageProps } from "sobria/router";

import SortSelect from "../../elements/sort-select.tsx";

export default function Posts({ url }: PageProps<"/posts">) {
  return <SortSelect search={url.search} />;
}
  • サーバーで描画するときは、getServerSnapshot の値を使用します。getServerSnapshot を省略した external をサーバーで実行すると、エラーになります。ブラウザだけで作成する要素では、省略できます
  • サーバーで描画した要素がブラウザで起動するときは、最初に getServerSnapshot の値で表示し、起動した後に getSnapshot の値に切り替えます。最初の表示をサーバーが出力した HTML と一致させるので、getServerSnapshot はサーバーとブラウザで同じ値を返す必要があります。要素の中ではリクエストの URL を参照できないので、上の例のように、ページが props で URL のクエリを渡します
  • getSnapshot は、状態が変化していなければ同じ値を返す必要があります。getSnapshot の値は Object.is で比較し、異なるときだけ表示を更新します
  • 要素を破棄すると、購読を解除します
  • ページの内容もクエリに合わせて変えるときは、URL を書き換えた後に refreshPage() を呼びます(サーバー関数)

配列を描画する

要素の中で {todos().map((todo) => <li>…</li>)} と記述すると、todos が変化するたびに、すべての項目の <li> を作成し直します。項目の中で作成した Signal と、入力中の <input> の値やフォーカスは失われます。配列の項目を追加、削除、並べ替えするときは、<For> で描画します。

// src/elements/todo-list.tsx
import { For, signal } from "sobria";

export default function TodoList(props: { todos: string[] }) {
  const [todos, setTodos] = signal(props.todos);
  return (
    <ul>
      <For each={todos()}>
        {(todo, index) => {
          const [done, setDone] = signal(false);
          return (
            <li>
              <input type="checkbox" checked={done()} onChange={() => setDone(!done())} />
              {index() + 1}. {todo}
              <button onClick={() => setTodos(todos().filter((item) => item !== todo))}>
                削除
              </button>
            </li>
          );
        }}
      </For>
    </ul>
  );
}

<For> は、子の関数を、項目が配列に追加されたときに 1 回だけ呼び出します。配列が変化しても、残った項目の <li> と、その中の Signal を保持します。

  • <For> は、更新の前と後の配列で、=== で等しい項目同士を対応付けます。オブジェクトの配列では、内容が同じでも、新しいオブジェクトに置換した項目は作成し直します
  • index は、項目の現在の位置を返す関数です。並べ替えると、index() の値が変化します
  • 並べ替えで位置が移動した項目の中の要素は、フォーカスが外れます
  • each が null か undefined のときは、何も描画しません

非同期の値を待つ

Loading

非同期の memo を、値が解決する前に読み取ると、最も近い <Loading> が fallback を表示します。内側の値がすべて解決すると、fallback を内側の内容に置換します。

fallback を表示するのは、初めて値を取得するときだけです。一度内側を表示した後に memo が値を取得し直すときは、取得が終わるまで古い内容を表示し続けます。

<Loading> は、子の要素の非同期の値も待ちます。そのため、複数の要素を 1 つの <Loading> で囲むと、1 つの fallback でまとめて待てます。どこにも <Loading> が無い要素は、値が解決するまで何も表示しません。

Errored

<Errored> は、内側で投げられたエラーと、非同期の memo の失敗を捕捉して、fallback を表示します。fallback に関数を渡すと、エラーと、内側を作り直す retry を引数に受け取ります。

サーバーで描画するときは、<Loading> の内側を後から送信することがあります。後から送信する内側の失敗をサーバーで捕捉するには、次の例のように <Errored> を <Loading> の内側に配置します。サーバーで描画するときに <Errored> が捕捉する範囲と、レスポンスのステータスは、ページとデータ に記述しています。

// src/elements/user-card.tsx
import { Errored, Loading, isPending, memo, refresh, signal } from "sobria";

import { getUser } from "../lib/users.ts";

export default function UserCard(props: { userId: number }) {
  const [id, setId] = signal(props.userId);
  const user = memo(() => getUser(id()));
  return (
    <>
      <Loading fallback={<p>読み込み中</p>}>
        <Errored fallback={(error, retry) => <button onClick={retry}>再試行</button>}>
          <h2 class={{ stale: isPending(user) }}>{user().name}</h2>
        </Errored>
      </Loading>
      <button onClick={() => setId(id() + 1)}>次のユーザー</button>
      <button onClick={() => void refresh(user)}>再読み込み</button>
    </>
  );
}

isPending

isPending(memo) は、一度解決した memo が、読み取った値の変化によって値を取得し直している間、true を返します。上の例では、次のユーザー のボタンを押してから新しいユーザーが届くまで、見出しに stale のクラスが付きます。初めて取得している間は <Loading> が fallback を表示するので、isPending は 2 回目以降の取得を表示に反映するときに使用します。

refresh

refresh(memo) は、読み取った値が変化していなくても、memo の値を取得し直します。戻り値は、取得し直した値が解決したときに解決する Promise です。refresh で取得し直している間は、isPending は false を返します。

ページ全体をサーバーで描き直すときは、refreshPage() を使用します(サーバー関数)。

action と楽観的更新

action は、書き込みの処理を 1 つのまとまりとして実行する関数を作成します。optimistic は、action の中で書き込むとすぐに表示に反映され、action が終わると元の値に戻る Signal を作成します。この 2 つを組み合わせると、サーバーのレスポンスを待たずに、操作の結果を先に表示できます。

// src/elements/like-count.tsx
import { action, memo, optimistic, refresh } from "sobria";

import { getUser, like } from "../lib/users.ts";

export default function LikeCount(props: { userId: number }) {
  const user = memo(() => getUser(props.userId));
  const [likes, setLikes] = optimistic(() => user().likes);
  const send = action(function* () {
    setLikes(likes() + 1);
    yield like(props.userId, { refresh: false });
    yield refresh(user);
  });
  return (
    <button disabled={send.pending()} onClick={() => void send()}>
      いいね {likes()}
    </button>
  );
}

ボタンを押すと、likes はすぐに 1 増えます。like はいいねを保存するサーバー関数です。GET 以外のサーバー関数を呼び出すと、sobria は呼び出し元のページを描き直します。いいねの数は要素の memo が取得し直すので、{ refresh: false } を指定して、ページの描き直しを省略します(サーバー関数)。action が refresh(user) を待っている間は、likes は書き込んだ値を保持します。action が終わると、likes は user().likes の値に戻ります。取得し直した user().likes は書き込んだ値と同じなので、表示される数は変化しません。

action には generator 関数(function*)を渡し、Promise は await の代わりに yield で待ちます。action の中の普通の Signal への書き込みは、yield の前後にかかわらず、action が終わるまで保留されます。async 関数の中で await の後に実行する処理は、action の外で実行されます。そのため、action に async 関数は渡せません。generator の中で呼び出して、その Promise を yield で待つ async 関数の中でも、await の後に refresh を待たないでください。action と refresh が互いの完了を待ち、どちらも完了しなくなることがあります。refresh は、action の generator の中で直接 yield で待ちます。

  • send.pending() は、action を実行している間、true を返します
  • action が失敗したときも、optimistic の値は元に戻ります
  • 同時に呼び出した action は、すべてが終わったときに一緒に終わります。例えば、send を続けて 2 回呼び出すと、表示は 2 増え、両方の action が終わるまでその値を保持します

yield の式の型は unknown になります。Promise が解決した値を使用するときは、yield* wait(promise) と記述します。この式の型は、Promise が解決する値の型になります。

// src/elements/rename-user.tsx
import { action, signal, wait } from "sobria";

import { updateUser } from "../lib/users.ts";

export default function RenameUser(props: { userId: number }) {
  const [message, setMessage] = signal("");
  const rename = action(function* (name: string) {
    const user = yield* wait(updateUser({ id: props.userId, name }));
    setMessage(`${user.name} に変更しました`);
  });
  return (
    <p>
      <button onClick={() => void rename("sobria")}>名前を変更</button>
      {message()}
    </p>
  );
}

サーバーで描画するときの振る舞い

要素の関数はサーバーでも実行し、その結果を HTML として送信します。サーバーで実行するときは、次のように振る舞います。

  • signal と memo は、初期の値を計算して保持します
  • effect と ctx.onMount に渡した関数は実行しません。ブラウザで要素が起動したときに初めて実行します
  • 非同期の memo は、サーバーで値を取得します。取得した値はページに埋め込んで送信し、ブラウザで要素が起動したときの memo の値になります。ブラウザは、埋め込まれた値を使用して、同じデータを取得し直しません。ただし、memo の関数が async iterable を返す memo、async iterable に解決する Promise を返す memo、サーバーで失敗した memo は、ブラウザで値を取得し直します。これらの memo は 読み込みと値の受け渡し で説明します

非同期の memo の中では、fetch を直接呼び出さずに、サーバー関数を呼び出します。ブラウザで要素が起動するときは、値を取得し直す memo を除いて、依存する値を記録するために memo の関数を 1 回実行します。この実行では、サーバー関数は通信しません。fetch を直接呼び出すと、この実行でも通信するので、同じデータを 2 回取得します。

サーバーで値が解決するまでの待ち方と、<Loading> の内容を後から送る仕組みは、ページとデータ で説明します。