Avatar

summerboys

3 public vals
Joined October 12, 2023

SSR React Mini & SQLite Todo App

This Todo App is server rendered and client-hydrated React. This architecture is a lightweight alternative to NextJS, RemixJS, or other React metaframeworks with no compile or build step. The data is saved server-side in Val Town SQLite.

demo

SSR React Mini Framework

This "framework" is currently 44 lines of code, so it's obviously not a true replacement for NextJS or Remix.

The trick is client-side importing the React component that you're server rendering. Val Town is uniquely suited for this trick because it both runs your code server-side and exposes vals as modules importable by the browser.

The tricky part is making sure that server-only code doesn't run on the client and vice-versa. For example, because this val colocates the server-side loader and action with the React component we have to be careful to do all server-only imports (ie sqlite) dynamically inside the loader and action, so they only run server-side.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource https://esm.sh/react */
import { zip } from "https://esm.sh/lodash-es";
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini?v=75";
export async function loader(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
`CREATE TABLE IF NOT EXISTS todos2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
text TEXT NOT NULL
)`,
`select * from todos2`,
]);
const initialTodos = rows.map(row => Object.fromEntries(zip(columns, row)));
return { initialTodos, initialLogs: [`Server rendered`] };
}
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const formData = await req.formData();
if (req.method === "POST") {
const text = formData.get("text") as string;
const [, { rows }] = await sqlite.batch([{
sql: `insert into todos2(text) values (?)`,
args: [text],
}, "select last_insert_rowid()"]);
return Response.json({ id: rows[0][0], text });
}
else if (req.method === "PUT") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
const { rows } = await sqlite.execute({ sql: `select completed_at from todos2 where id = ?`, args: [id] });
const completed_at = rows[0][0];
const new_completed_at = completed_at ? null : new Date() as any;
await sqlite.execute({ sql: `update todos2 set completed_at = ? where id = ?`, args: [new_completed_at, id] });
}
else if (req.method === "DELETE") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
await sqlite.execute({ sql: `delete from todos2 where id = ?`, args: [id] });
}
return Response.json("OK");
}
export function Component({ initialTodos, initialLogs }) {
const [todos, setTodos] = useState(initialTodos);
const [logs, setLogs] = useState(initialLogs);
const [newTodo, setNewTodo] = useState("");
const addLog = log => setLogs([...logs, log]);
useEffect(() => addLog(`Client rendered`), []);
function addTodo() {
setTodos([...todos, { text: newTodo }]);
setNewTodo("");
return async (resp: Response) => {
const { text, id } = await resp.json();
setTodos([...todos, { text, id }]); // ok to add again because todos is stale
addLog(`Got ${resp.ok ? "OK" : "Error"} from server for adding todo`);
};
}
function toggleTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.map(t => t.id === id ? { ...t, completed_at: t.completed_at ? null : Date.now() } : t));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for toggling todo`);
}
function deleteTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.filter(t => t.id !== id));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for deleting todo`);
}
return (
<html>
<head>
<title>Todo List</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h2 className="text-xl font-bold pb-2">Todo list</h2>
<ol className="pl-2 max-w-64">
{todos.map((todo) => (
<li key={todo.id} className={`flex justify-between ${todo.completed_at ? "line-through" : ""}`}>
<div className="flex">
<Form action="put" className="mr-2" onSubmit={toggleTodo}>
<input name="id" value={todo.id} type="hidden" />
<button>{todo.completed_at ? "✅" : "o"}</button>
</Form>
{todo.text}
</div>
<Form action="delete" onSubmit={deleteTodo}>
<input name="id" value={todo.id} type="hidden" />

Poll RSS feeds

This val periodically polls specified RSS feeds and send the author an email with new items. It checks each feed defined in rssFeeds for new content since the last run and sends an email with the details of the new items.

Usage

  1. Fork @stevekrouse/rssFeeds and update it with your desired RSS feeds;
  2. Fork this val and replace the https://esm.town/v/stevekrouse/rssFeeds import with your newly forked val;
  3. Enjoy RSS updates on your email!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { email } from "https://esm.town/v/std/email?v=9";
import { newRSSItems } from "https://esm.town/v/stevekrouse/newRSSItems";
import { rssFeeds } from "https://esm.town/v/stevekrouse/rssFeeds";
export async function pollRSSFeeds({ lastRunAt }: Interval) {
return Promise.all(
Object.entries(rssFeeds).map(async ([name, url]) => {
let items = await newRSSItems({
url,
lastRunAt,
});
if (items.length)
await email({
text: JSON.stringify(items, null, 2),
subject: `New from ${name} RSS`,
});
return { name, items };
}),
);
}

HTML example

This is an example of how to return an HTML response.

You can also preview it at https://andreterron-htmlExample.web.val.run?name=Andre

1
2
3
4
5
6
7
8
9
10
11
12
// View at https://andreterron-htmlExample.web.val.run?name=Andre
export default async function(req: Request): Promise<Response> {
const query = new URL(req.url).searchParams;
// Read name from the querystring or body. Defaults to "you" if not present.
const name = query.get("name") || (await req.json().catch(() => ({}))).name || "you";
// Returns the HTML response
return new Response(`<h1>Hi summer ${name}!</h1>`, {
headers: { "Content-Type": "text/html" },
});
}
Next