Skip to main content

Architecture III: The Client

Pixel art of a desk with a monitor showing a slider bound to a number label by a glowing amber wire. The same wire runs out the window, across a teal city skyline, and into a distant filing vault guarded by a tiny figure. Tagline: Same Binding. Longer Wire.

Reminders​

  • Office hours: on Discord, or by appointment
  • Discussion also on Discord: Join via Pawtograder
  • DUE TONIGHT, Wed Sep 16: The Ticket Hunt
  • DUE Thu Sep 17: Project Bids
  • DUE Thu Sep 24: Onboarding: Gradebook Column Groups

The Ticket Hunt Closes Tonight, 23:59​

Two tickets you file

GitHub issues on pawtograder/platform, labeled cs4535-hunt.

From two different roles. One is you as a student; the second is the role you claimed on Sep 10.

Steps a stranger can follow · expected vs. actual · where you saw it · who it affects · evidence · why it matters.

Search first. Duplicates are the most common way this goes wrong.

Two items you triage

Comments on somebody else's item. Axe findings in #910, a TODO in the tree, or an unlabeled issue.

One of three verdicts: still real (with a size), no longer real (with how you know), or undecidable (naming what you'd need and who has it).

The reasoning is what's assessed. "Still relevant" earns nothing.

It lands in two places. GitHub is where the work goes. The hunt form in Pawtograder is the record that you did it, and it closes at the same moment.

CS 4535: Software Design & Delivery

Architecture III: The Client​

©2026 Jonathan Bell, CC-BY-SA

Learning Objectives​

After this session, you'll be able to:

  1. Trace one value from a Postgres row to a rendered cell, and say how it updates without a reload
  2. Decide which layer a change belongs in: migration, RPC, controller, hook, or component
  3. Say what the gradebook's grouping is derived from today, what its interface is, and what your migration has to preserve

How Today Works​

Every client application, in every language, answers three questions. Today is those three questions, and this codebase's answers:

1. Where does the data live while the user is looking at it?

2. How does the screen find out it changed?

3. What is recomputed, and what is stored?

Two working breaks, in pairs. No TypeScript required for either one, and none of today needs you to have written React.

Question 3 is the assignment due on the 24th.

There Is No Backend​

app/course/[course_id]/layout.tsx, the server component every course page sits under, in full:

authenticate the user
resolve their role in this course, or redirect away
if they're a student, prefetch their rows so the first paint isn't empty
hand everything to client-side providers

108

lines in that file

430 / 637

components marked "use client"

2

files in the repo marked "use server"

Below that layout, the browser talks to Postgres.

Two Gates, and Neither One Is In Your Component​

Diagram with two lanes. The pull lane runs from a browser through a teal shield labeled RLS policy, once per row, into Postgres. The push lane runs from Postgres back to the browser through an amber shield labeled channel topic, beside the three topic names class:123:staff, class:123:students and class:123:user:abc. Between the lanes a small box labeled your React component connects to both by dashed lines, captioned no authorization here.

Watch It Move​

Two windows. Instructor gradebook on the left, the same course as a student on the right.

Change one score.

No reload. No polling. One websocket frame, and one cell repainted.

Discussion: Where Does This Change Go?​

Six minutes, in pairs. For each request, pick a layer and name one consequence. No code.

LAYERS   migration  ·  RPC  ·  controller  ·  hook  ·  component
  1. An instructor can rename a column group.
  2. The group header shows how many columns are in it.
  3. Students never see a group that hasn't been released.
  4. The gradebook shows a class average for each group.

One of these is not the layer it looks like.

You've Bound a Label to a Property Before​

JavaFX, if you built the smart home app

brightnessValueLabel.textProperty().bind(
light.brightnessProperty().asString("%d%%"));

Pawtograder, useGradebookColumn(id)

const [column, setColumn] = useState(
controller.rows.find(c => c.id === id));

useEffect(() =>
controller.getById(id, setColumn).unsubscribe,
[id]);

return column;

Both say the same thing: bind once, never refresh by hand. The right-hand one writes out the unsubscribe, because nothing owns the lifetime for you.

A hook is a ViewModel. That's the whole idea.

The Gotcha You Already Met​

ObservableList fires when items are added or removed, but not when a property inside an existing item changes.

Diagram of one TableController box on the left fanning out into three separate groups of arrows: two teal arrows labeled list listeners, the whole table repaints; five blue arrows labeled row listeners by id, one header repaints; and about twenty amber arrows labeled cell listeners by student and column, one cell repaints, ending at a spreadsheet grid with a single cell highlighted. Caption: 40 columns times 400 students.

One Query Per Table, Per Course​

get gradebookColumns() {
if (!this._gradebookColumns) {
this._gradebookColumns = new TableController({
client: this.client,
table: "gradebook_columns",
query: this.client.from("gradebook_columns")
.select("*")
.eq("class_id", this.courseId),
classRealTimeController: this.classRealTimeController,
initialData: this._initialData?.gradebookColumns
});
}
return this._gradebookColumns;
}

Built on first use. One query, scoped to one course. Every component on the page shares it.

The Schema Is the Contract​

utils/supabase/SupabaseTypes.d.ts is 14,494 lines, and nobody wrote it.

npx supabase migration new add_column_groups   # write the SQL
npx supabase db reset # replay it from scratch
npm run client-local # regenerate the types
npm run seed -- --template cs4535 # db reset dropped your class

Skip the third command and you get type errors in files you never opened.

Eight Things You'll Meet In That File​

Self-study. Enough to read gradebookTable.tsx without a React course.

useState(x)a field that repaints its component when it changes
useEffect(fn, [deps])run fn after render; re-run when deps change. Return a cleanup function
useMemo(fn, [deps])cache fn's result until deps change
<Thing prop={x} />construct Thing, passing x. Arguments, spelled like HTML
{expr} in markupevaluate expr and render it
Foo<Bar>a generic, same as Java
a?.bb, or undefined if a is null
a ?? ba, unless it's null, then b

What the Memo Actually Does​

gradebookTable.tsx, around line 2556. React removed; this is all of it.

for (const col of columns) {                      // sorted by sort_order
const parts = col.slug.split("-");

const base = (parts[0] === "assignment" && parts.length >= 3)
? parts[0] + "-" + parts[1] // assignment-lab, assignment-exam
: parts[0] || "other";

const contiguous = lastSortOrder === -1
|| col.sort_order === lastSortOrder + 1; // no gap since the last one

if (!contiguous || base !== currentGroupKey) {
currentGroupKey = base;
currentGroupIndex++; // start a new group here
}

groups[base + "-" + currentGroupIndex].columns.push(col);
lastSortOrder = col.sort_order;
}

A group is a string prefix plus an unbroken run of integers. That is the entire concept.

The Memo Is Not the Interface​

What sixty lines of slug-splitting produces:

Record<string, { groupName: string; columns: Column[] }>

groupedColumns is referenced 18 times in that file: the table headers, the virtualizer's height math, the collapse state, and the drag-to-reorder path.

Keep the shape. Change where it comes from.

Three Copies, Not Two​

The same slug-splitting rule is written out in three places:

WhereWhat it decides
gradebookTable.tsx ~2556the instructor gradebook's headers
whatIf.tsx ~543what students see in the what-if view
gradebookTable.tsx ~229which block a drag moves

And none of them exist in SQL. No report, export, or edge function can see a group at all.

Sketch It. No TypeScript.​

Five minutes, in pairs. Get Monday's sketch out. Groups are rows now.

SHAPE    what does the thing the table reads look like?
............................................

OWNER who sorts the groups, and who decides collapsed?
............................................

CHANGE somebody renames a group in another browser.
what happens in yours, and how?
............................................

KEEP what must still be true for the other 17 callers?
............................................

Where Sketches Come Apart​

The group key gets rebuilt on the client anyway, out of habit. If you have the row, you have the id.

Sorting drifts into the component. Group order is everyone's; collapsed is only yours.

CHANGE is blank. A rename in another browser has to arrive somehow, and you already watched it happen.

Key Takeaways​

Where does the data live? In one controller per table per course, shared by every component, kept current by the database rather than by a refresh button.

How does the screen find out? A row changed, a topic carried it, one listener fired, one cell repainted. Authorization happened twice and neither time in your component.

Derived or stored? Today a column group is derived, three times, from a string nobody is allowed to edit. Next week you store it.

Keep the shape. Change where it comes from.

Up Next​

Thu Sep 17: Reliably Releasing Software

You now know how a change reaches a browser. Tomorrow is how it reaches production without taking anything down: dark launches, safe migrations, and rollback as the first response rather than the last.

Before then:

  • The Ticket Hunt, tonight 23:59. GitHub and the form
  • Project Bids, due Thu Sep 17, 23:59
  • Column Groups, due Thu Sep 24, 23:59. Eight days