Likes / Votes Toggle
elements man recipes/likes-toggle Read as markdownA "like" or "vote" toggles a row in a join table: insert when the user toggles
on, delete when they toggle off. The pattern is the canonical Elements answer
for likes, hearts, upvotes, bookmarks, follows, and similar
one-row-per-user-per-thing reactions. Partition the LiveTable by the parent id
so each item's likes are their own channel; the insert handler stamps the row
with the caller, and delete restricts removal to the row's owner.
Migration
Scaffold the migration with elements create migration. The -tables flag
stubs out the table with the standard primary key, timestamps, and
touchUpdatedAt trigger so you don't write that boilerplate by hand.
elements create migration 'add comment likes' -tables=commentLikes
That writes app/migrations/<timestamp>-add-comment-likes.migration.sql. Open
it and add the foreign-key columns plus the unique constraint, the parts
specific to this feature.
-- add comment likes
-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table commentLikes (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
commentId uuid not null references comments(id) on delete cascade,
userId uuid not null references users(id) on delete cascade,
unique (commentId, userId)
);
create trigger commentLikesTouchUpdatedAt
before update on commentLikes
for each row execute function touchUpdatedAt();
The three lines added on top of the scaffold are commentId, userId, and
unique (commentId, userId). The unique constraint is the safety net at the
database: even if two clicks race, only one row lands.
Comment template
elements create template comment
That writes app/shared/templates/comment/{index.html, style.css}. Update
index.html.
The template declares its own LiveTable at module level and exports it so parent pages can open a view per row. The toggle helper sits next to the template since it's only called from inside it.
app/shared/templates/comment/index.html:
import "./style.css";
import { LiveTable, LiveView, ForbiddenError, session } from "@elements/app";
export interface Comment {
id: string;
postId: string;
userId: string;
body: string;
}
export interface CommentLike {
id: string;
commentId: string;
userId: string;
}
export let commentLikes: LiveTable<CommentLike> = new LiveTable<CommentLike>({
insert: (item) => {
session.isLoggedInOrThrow();
return commentLikes.insert({ ...item, userId: session.getOrThrow('userId') });
},
delete: (item) => {
session.isLoggedInOrThrow();
if (item.userId !== session.getOrThrow('userId')) {
throw new ForbiddenError();
}
return commentLikes.delete(item);
},
});
function toggle(likes: LiveView<CommentLike>) {
let mine = likes.find(l => l.userId === session.getOrThrow('userId'));
if (mine) {
likes.delete(mine);
} else {
likes.insert({ userId: session.getOrThrow('userId') });
}
}
<CommentItem (c: Comment, likes: LiveView<CommentLike>)>
<p>{c.body}</p>
<button onclick={() => toggle(likes)}>
{likes.length} {likes.length === 1 ? "like" : "likes"}
</button>
</CommentItem>
The page below opens the table with view({ commentId }), which partitions the
channel per comment, so the count for one comment only updates templates
watching that comment. Both handlers call session.isLoggedInOrThrow() first,
which throws AuthError for an anonymous caller; only then do they read
session.getOrThrow('userId'). insert overwrites userId with the session's,
so a signed-in user cannot like as somebody else. delete refuses a row the
caller does not own.
Both end in the raw auto-SQL on the declaration, commentLikes.insert and
commentLikes.delete; the declaration carries an explicit
LiveTable<CommentLike> annotation because its initializer refers to it.
likes.length is reactive, so the button label updates on every insert and
delete.
Post page
elements create page post
The page renders a list of comments and opens one view per comment, then ships
each view to <CommentItem> via the likes attribute. view() runs at
route time on the server, and the view serializes to the browser as a live
handle.
app/pages/post/services.ts:
export interface Post {
id: string;
title: string;
}
app/pages/post/index.ts:
import { sql, LiveView } from "@elements/app";
import { Comment, CommentLike, commentLikes } from "#app/shared/templates/comment";
import post from "./template";
import { Post } from "./services";
export default function route(req, res) {
let p = sql<Post>(`select * from posts where id = ${req.params.id}`).firstOrThrow("post not found");
let comments = sql<Comment>(`select * from comments where postId = ${p.id}`).all();
let likes: Record<string, LiveView<CommentLike>> = {};
for (let c of comments) {
likes[c.id] = commentLikes.view({ commentId: c.id });
}
return new post({ post: p, comments, likes });
}
firstOrThrow("post not found") raises NotFoundError (a safe 404) when no row
matches, so the client gets a clean response instead of a runtime null deref.
The likes are opened here, one view per comment, because view() is the route's
job: it reads rows and opens a subscription, and a template renders on the
browser too, where there is no database to read and no channel to open. Keying
them by comment id gives the template a view to hand each row.
app/pages/post/template.html:
import "./style.css";
import { LiveView } from "@elements/app";
import CommentItem, {
Comment,
CommentLike,
} from "#app/shared/templates/comment";
import { Post } from "./services";
<html class="post" (post: Post,
comments: Comment[],
likes: Record<string, LiveView<CommentLike>>)>
<main>
<h1>{post.title}</h1>
<CommentItem e:for={c of comments} c={c} likes={likes[c.id]}/>
</main>
</html>
Routes
Register the page in index.ts:
import post from "#app/pages/post";
// ...
app.route("/posts/:id", post);
Notes
- The partition fills
commentIdon insert, so the payload intogglecarries onlyuserId. PassingcommentIdwith a value other than the view's throwsPartitionMismatchErrorbefore anything is sent. - A second click while a delete is in flight is a no-op (the row is already gone from local state). Optimistic state guards the user from accidentally double-mutating.
- The same shape handles upvote / downvote (store the direction as a
value: 1 | -1column), star ratings (store aratingint), or any other one-row-per-user-per-thing reaction.