Skip to content

Commit 7a3c886

Browse files
committed
feat(examples): add Arrow.js SSR example
Todo app showcasing reactive state, components with props, keyed lists, computed filtering, watch effects, and SSR with client-side takeover.
1 parent 9ce219c commit 7a3c886

File tree

8 files changed

+713
-5
lines changed

8 files changed

+713
-5
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"type": "module",
3+
"scripts": {
4+
"build": "vite build",
5+
"preview": "vite preview",
6+
"dev": "vite dev"
7+
},
8+
"devDependencies": {
9+
"@arrow-js/core": "latest",
10+
"@arrow-js/framework": "latest",
11+
"@arrow-js/hydrate": "latest",
12+
"@arrow-js/ssr": "latest",
13+
"nitro": "latest",
14+
"vite": "latest"
15+
},
16+
"TODO": [
17+
"@arrow-js/vite-plugin-arrow - investigate workspace alias plugin for external use",
18+
"@arrow-js/hydrate - investigate hydration adoption (currently using client-side takeover)"
19+
]
20+
}

examples/vite-ssr-arrow/src/app.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { html, reactive, component, watch } from "@arrow-js/core";
2+
import type { Props } from "@arrow-js/core";
3+
4+
type Todo = { id: number; text: string; done: boolean };
5+
type Filter = "all" | "active" | "done";
6+
7+
const TodoItem = component(
8+
(
9+
props: Props<{
10+
todo: Todo;
11+
onToggle: (id: number) => void;
12+
onRemove: (id: number) => void;
13+
}>
14+
) => {
15+
return html`
16+
<li class="${() => (props.todo.done ? "todo done" : "todo")}">
17+
<button
18+
class="toggle"
19+
@click="${() => props.onToggle(props.todo.id)}"
20+
>
21+
${() => (props.todo.done ? "\u2713" : "")}
22+
</button>
23+
<span>${() => props.todo.text}</span>
24+
<button
25+
class="remove"
26+
@click="${() => props.onRemove(props.todo.id)}"
27+
>
28+
\u00d7
29+
</button>
30+
</li>
31+
`;
32+
}
33+
);
34+
35+
const Filters = component((props: Props<{ filter: Filter }>) => {
36+
const filters: Filter[] = ["all", "active", "done"];
37+
return html`
38+
<nav class="filters">
39+
${filters.map(
40+
(f) => html`
41+
<button
42+
class="${() => (props.filter === f ? "active" : "")}"
43+
@click="${() => {
44+
props.filter = f;
45+
}}"
46+
>
47+
${f}
48+
</button>
49+
`
50+
)}
51+
</nav>
52+
`;
53+
});
54+
55+
export function App() {
56+
const state = reactive({
57+
todos: [
58+
{ id: 1, text: "Learn reactive state", done: true },
59+
{ id: 2, text: "Build a component", done: false },
60+
{ id: 3, text: "Render a keyed list", done: false },
61+
] as Todo[],
62+
input: "",
63+
filter: "all" as Filter,
64+
nextId: 4,
65+
});
66+
67+
const filtered = () => {
68+
if (state.filter === "active") return state.todos.filter((t) => !t.done);
69+
if (state.filter === "done") return state.todos.filter((t) => t.done);
70+
return state.todos;
71+
};
72+
73+
const remaining = () => state.todos.filter((t) => !t.done).length;
74+
75+
const addTodo = () => {
76+
const text = state.input.trim();
77+
if (!text) return;
78+
state.todos.push({ id: state.nextId, text, done: false });
79+
state.nextId++;
80+
state.input = "";
81+
};
82+
83+
const onToggle = (id: number) => {
84+
const todo = state.todos.find((t) => t.id === id);
85+
if (todo) todo.done = !todo.done;
86+
};
87+
88+
const onRemove = (id: number) => {
89+
const i = state.todos.findIndex((t) => t.id === id);
90+
if (i >= 0) state.todos.splice(i, 1);
91+
};
92+
93+
watch(() => {
94+
console.log(
95+
`[Arrow.js] ${remaining()} of ${state.todos.length} todos remaining`
96+
);
97+
});
98+
99+
return html`
100+
<div class="app">
101+
<h1>Nitro + Arrow.js</h1>
102+
<p class="subtitle">A ~3KB reactive UI with SSR</p>
103+
104+
<div class="input-row">
105+
<input
106+
type="text"
107+
placeholder="What needs to be done?"
108+
.value="${() => state.input}"
109+
@input="${(e: Event) => {
110+
state.input = (e.target as HTMLInputElement).value;
111+
}}"
112+
@keydown="${(e: Event) => {
113+
if ((e as KeyboardEvent).key === "Enter") addTodo();
114+
}}"
115+
/>
116+
<button class="add" @click="${addTodo}">Add</button>
117+
</div>
118+
119+
${Filters(state)}
120+
121+
<ul class="todo-list">
122+
${() =>
123+
filtered().map((todo) =>
124+
TodoItem({ todo, onToggle, onRemove }).key(todo.id)
125+
)}
126+
</ul>
127+
128+
<footer class="footer">
129+
<span>${() => remaining()} items left</span>
130+
<button
131+
class="clear"
132+
@click="${() => {
133+
state.todos = state.todos.filter((t) => !t.done);
134+
}}"
135+
>
136+
Clear done
137+
</button>
138+
</footer>
139+
</div>
140+
`;
141+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { App } from "./app.ts";
2+
3+
const root = document.getElementById("app")!;
4+
root.textContent = "";
5+
App()(root);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import "./styles.css";
2+
import { renderToString } from "@arrow-js/ssr";
3+
import { App } from "./app.ts";
4+
5+
import clientAssets from "./entry-client?assets=client";
6+
import serverAssets from "./entry-server?assets=ssr";
7+
8+
export default {
9+
async fetch(_req: Request) {
10+
const assets = clientAssets.merge(serverAssets);
11+
const view = App();
12+
const result = await renderToString(view);
13+
14+
const head = [
15+
`<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
16+
...assets.css.map(
17+
(attr: Record<string, string>) =>
18+
`<link rel="stylesheet" href="${attr.href}" />`
19+
),
20+
...assets.js.map(
21+
(attr: Record<string, string>) =>
22+
`<link rel="modulepreload" href="${attr.href}" />`
23+
),
24+
`<script type="module" src="${assets.entry}"></script>`,
25+
].join("\n ");
26+
27+
const html = `<!doctype html>
28+
<html lang="en">
29+
<head>
30+
${head}
31+
</head>
32+
<body>
33+
<div id="app">${result.html}</div>
34+
</body>
35+
</html>`;
36+
37+
return new Response(html, {
38+
headers: { "Content-Type": "text/html;charset=utf-8" },
39+
});
40+
},
41+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
* {
2+
box-sizing: border-box;
3+
margin: 0;
4+
}
5+
6+
body {
7+
font-family: system-ui, sans-serif;
8+
background: #1a1a2e;
9+
color: #eee;
10+
display: flex;
11+
justify-content: center;
12+
padding: 2rem 1rem;
13+
}
14+
15+
.app {
16+
max-width: 480px;
17+
width: 100%;
18+
}
19+
20+
h1 {
21+
font-size: 1.8rem;
22+
color: #e2b340;
23+
}
24+
25+
.subtitle {
26+
color: #888;
27+
margin-bottom: 1.5rem;
28+
}
29+
30+
.input-row {
31+
display: flex;
32+
gap: 0.5rem;
33+
margin-bottom: 1rem;
34+
}
35+
36+
.input-row input {
37+
flex: 1;
38+
padding: 0.6rem 0.8rem;
39+
border: 1px solid #333;
40+
border-radius: 6px;
41+
background: #16213e;
42+
color: #eee;
43+
font-size: 0.95rem;
44+
}
45+
46+
.input-row input:focus {
47+
outline: none;
48+
border-color: #e2b340;
49+
}
50+
51+
button {
52+
cursor: pointer;
53+
border: none;
54+
border-radius: 6px;
55+
font-size: 0.85rem;
56+
padding: 0.5rem 0.8rem;
57+
background: #16213e;
58+
color: #ccc;
59+
transition: background 0.15s;
60+
}
61+
62+
button:hover {
63+
background: #1a2744;
64+
}
65+
66+
button.add {
67+
background: #e2b340;
68+
color: #1a1a2e;
69+
font-weight: 600;
70+
}
71+
72+
button.add:hover {
73+
background: #f0c850;
74+
}
75+
76+
.filters {
77+
display: flex;
78+
gap: 0.4rem;
79+
margin-bottom: 1rem;
80+
}
81+
82+
.filters button.active {
83+
background: #e2b340;
84+
color: #1a1a2e;
85+
}
86+
87+
.todo-list {
88+
list-style: none;
89+
padding: 0;
90+
}
91+
92+
.todo {
93+
display: flex;
94+
align-items: center;
95+
gap: 0.6rem;
96+
padding: 0.6rem 0;
97+
border-bottom: 1px solid #222;
98+
}
99+
100+
.todo.done span {
101+
text-decoration: line-through;
102+
opacity: 0.5;
103+
}
104+
105+
.todo span {
106+
flex: 1;
107+
}
108+
109+
.todo .toggle {
110+
width: 28px;
111+
height: 28px;
112+
border-radius: 50%;
113+
border: 2px solid #444;
114+
background: transparent;
115+
color: #4caf50;
116+
font-size: 1rem;
117+
display: flex;
118+
align-items: center;
119+
justify-content: center;
120+
padding: 0;
121+
}
122+
123+
.todo.done .toggle {
124+
border-color: #4caf50;
125+
}
126+
127+
.todo .remove {
128+
background: transparent;
129+
color: #e74c3c;
130+
font-size: 1.1rem;
131+
opacity: 0;
132+
transition: opacity 0.15s;
133+
padding: 0.2rem 0.5rem;
134+
}
135+
136+
.todo:hover .remove {
137+
opacity: 1;
138+
}
139+
140+
.footer {
141+
display: flex;
142+
justify-content: space-between;
143+
align-items: center;
144+
margin-top: 1rem;
145+
padding-top: 0.8rem;
146+
border-top: 1px solid #222;
147+
color: #888;
148+
font-size: 0.85rem;
149+
}
150+
151+
button.clear {
152+
color: #e74c3c;
153+
background: transparent;
154+
font-size: 0.8rem;
155+
}
156+
157+
button.clear:hover {
158+
background: rgba(231, 76, 60, 0.1);
159+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true
9+
},
10+
"include": ["src"]
11+
}

0 commit comments

Comments
 (0)