要素の状態は 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> の内容を後から送る仕組みは、ページとデータ で説明します。