# Likes / Votes Toggle A "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. ```bash elements create migration 'add comment likes' -tables=commentLikes ``` That writes `app/migrations/-add-comment-likes.migration.sql`. Open it and add the foreign-key columns plus the unique constraint, the parts specific to this feature. ```sql -- 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 ```bash elements create template comment ``` That writes `app/shared/templates/comment/{index.ehtml, style.css}`. Update `index.ehtml`. 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.ehtml`: ```ehtml 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 = new LiveTable({ 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 onToggle(likes: LiveView) { let mine = likes.find(l => l.userId === session.getOrThrow('userId')); if (mine) { likes.delete(mine); } else { likes.insert({ userId: session.getOrThrow('userId') }); } } )>

{c.body}

``` 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` annotation because its initializer refers to it. `likes.length` is reactive, so the button label updates on every insert and delete. ## Post page ```bash elements create page post ``` The page renders a list of comments and opens one view per comment, then ships each view to `` 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/index.ts`: ```ts import { Request, Response, sql, LiveView } from "@elements/app"; import { Comment, CommentLike, commentLikes } from "#app/shared/templates/comment"; import post, { Post } from "./template"; export default function route(req: Request, res: Response) { let p = sql(`select * from posts where id = ${req.params.id}`).firstOrThrow("post not found"); let comments = sql(`select * from comments where postId = ${p.id}`).all(); let likes: Record> = {}; 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.ehtml`: ```ehtml import "./style.css"; import { LiveView } from "@elements/app"; import CommentItem, { Comment, CommentLike, } from "#app/shared/templates/comment"; export interface Post { id: string; title: string; } >, )>

{post.title}

``` ## Routes Register the page in `index.ts`: ```ts import post from "#app/pages/post"; // ... app.route("/posts/:id", post); ``` ## Notes - The partition fills `commentId` on insert, so the payload in `onToggle` carries only `userId`. Passing `commentId` with a value other than the view's throws `PartitionMismatchError` before 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 | -1` column), star ratings (store a `rating` int), or any other one-row-per-user-per-thing reaction.