1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { createFileRoute } from "@tanstack/react-router";
3import * as AsyncResult from "effect/reactivity/AsyncResult";
4import { useState } from "react";
5import {
6 createTodoAtom,
7 deleteTodoAtom,
8 listTodosAtom,
9 toggleTodoAtom,
10} from "../rpc-client.ts";
11
12export const Route = createFileRoute("/")({
13 component: Home,
14});
15
16function Home() {
17 return (
18 <main style={{ maxWidth: 640, margin: "0 auto", padding: "4rem 1.5rem" }}>
19 <h1 style={{ margin: 0, fontSize: "2.25rem" }}>Todos</h1>
20 <p style={{ marginTop: "0.75rem", color: "#475569", lineHeight: 1.6 }}>
21 TanStack Start UI &rarr; <code>AtomRpc</code> client &rarr; Effect RPC
22 worker &rarr; Drizzle &rarr; Neon Postgres.
23 </p>
24 <section style={{ marginTop: "2rem" }}>
25 <TodoForm />
26 <TodoList />
27 </section>
28 </main>
29 );
30}
31
32function TodoForm() {
33 const createTodo = useAtomSet(createTodoAtom);
34
35 const [text, setText] = useState("");
36
37 const submit = (e: React.SubmitEvent) => {
38 e.preventDefault();
39 const value = text.trim();
40 if (!value) return;
41 createTodo({ payload: { text: value }, reactivityKeys: ["todos"] });
42 setText("");
43 };
44
45 return (
46 <form onSubmit={submit} style={{ display: "flex", gap: "0.5rem" }}>
47 <input
48 value={text}
49 onChange={(e) => setText(e.target.value)}
50 placeholder="What needs doing?"
51 style={{
52 flex: 1,
53 padding: "0.6rem 0.75rem",
54 borderRadius: 8,
55 border: "1px solid #cbd5e1",
56 fontSize: "1rem",
57 }}
58 />
59 <button type="submit" style={primaryButton}>
60 Add
61 </button>
62 </form>
63 );
64}
65
66function TodoList() {
67 const atom = useAtomValue(listTodosAtom);
68 const toggleTodo = useAtomSet(toggleTodoAtom);
69 const deleteTodo = useAtomSet(deleteTodoAtom);
70
71 const todos = AsyncResult.getOrElse(atom, () => []);
72
73 if (
74 (AsyncResult.isWaiting(atom) && !todos.length) ||
75 typeof window === "undefined"
76 ) {
77 return (
78 <p style={{ color: "#94a3b8", marginTop: "1.5rem" }}>Loading todos…</p>
79 );
80 }
81
82 if (!todos.length) {
83 return (
84 <p style={{ color: "#94a3b8", marginTop: "1.5rem" }}>
85 No todos yet — add one above.
86 </p>
87 );
88 }
89
90 return (
91 <ul style={{ listStyle: "none", padding: 0, marginTop: "1.5rem" }}>
92 {todos.map((todo) => (
93 <li
94 key={todo.id}
95 style={{
96 display: "flex",
97 alignItems: "center",
98 gap: "0.75rem",
99 padding: "0.6rem 0",
100 borderBottom: "1px solid #e2e8f0",
101 }}
102 >
103 <input
104 type="checkbox"
105 checked={todo.done}
106 onChange={() =>
107 toggleTodo({
108 payload: { id: todo.id, done: !todo.done },
109 reactivityKeys: ["todos"],
110 })
111 }
112 style={{ width: 18, height: 18 }}
113 />
114 <span
115 style={{
116 flex: 1,
117 textDecoration: todo.done ? "line-through" : "none",
118 color: todo.done ? "#94a3b8" : "#0f172a",
119 }}
120 >
121 {todo.text}
122 </span>
123 <button
124 type="button"
125 onClick={() =>
126 deleteTodo({
127 payload: { id: todo.id },
128 reactivityKeys: ["todos"],
129 })
130 }
131 style={ghostButton}
132 >
133 Delete
134 </button>
135 </li>
136 ))}
137 </ul>
138 );
139}
140
141const primaryButton: React.CSSProperties = {
142 padding: "0.6rem 1.1rem",
143 border: "none",
144 borderRadius: 8,
145 background: "#0f172a",
146 color: "#fff",
147 cursor: "pointer",
148 fontSize: "1rem",
149};
150
151const ghostButton: React.CSSProperties = {
152 padding: "0.35rem 0.6rem",
153 border: "none",
154 borderRadius: 6,
155 background: "transparent",
156 color: "#ef4444",
157 cursor: "pointer",
158 fontSize: "0.875rem",
159};