01 / The idea
“Put this cue after that one.”
You have almost certainly never typed new LinkedList() at work. Yet if you
write React, every component with state, refs, or effects is holding one. Hooks like useState and useEffect each get a node in a chain, in the order you call them, each
pointing to the next. Put a hook inside an if and the chain stops lining up with the calls. That is the whole reason the rule
exists.
The cue sheet gives us the same structure with something we can see. It starts House lights → Opening music → Curtain. Add Stage spot after Opening music. Then move that same Stage spot after House lights.
An array would do for a small sheet: find the position, splice. A linked list makes a different promise. Once you are holding a particular node, you can change its neighbors without shifting anything after it. The condition is the point: once you are holding the node. If all you have is the name “Opening music,” someone still has to go and find it. This lesson keeps that search visible.
02 / Name the rule
A node has a value and a way to reach its neighbor.
A linked list keeps order through links: each node holds a value and a reference to the next node. Ours is doubly linked, so each node also knows the one before it.
The list holds a head and a tail. Head has no previous; tail has no next. Walk next from head and you visit every live node once. Walk previous from tail and you get the reverse. Both directions have to agree. Empty means neither end.
A handle is how a caller names one node without touching its links. Cue #4 is still #4 after it moves from third to second. Position changed; identity did not. Two nodes can even hold the same name.
Are those cards memory addresses?Logical order and storage layout
No. The cards stay in creation order and the running-order strip follows the links; where a card sits is schematic. TypeScript and Go link nodes by reference. What makes it a linked list is how order is represented, not where the bytes live.
03 / Follow one operation
Find the anchor. Change the links.
Find Opening music visits #1, then #2. Insert Stage spot creates #4 between #2 and #3. Move it earlier and the old neighbors reconnect, then the same #4 lands between #1 and #2.
Each sheet you see is the finished result of one call. The highlights replay the visits and link changes it recorded; you never see a half-edited list. Step through holds each result. Try it runs the same TypeScript on your own cues.
Follow the cue sheet.
- #1 House lights
- #2 Opening music
- #3 Curtain
Node IDs stay fixed. The links determine the running order.
0 visited · 0 link fields changedLogical links; card positions are schematic.
Three cues. One order.
Follow next from the head; previous leads back.
Reduced motion: choose a scene to see its completed state.
Read this scene
Follow next from the head; previous leads back.
Completed order: #1 House lights → #2 Opening music → #3 Curtain. Head #1; tail #3; held none.
0 cues visited; 0 link fields changed. Follow next from the head; previous leads back.
- #1: previous none, next #2.
- #2: previous #1, next #3.
- #3: previous #2, next none.
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh cue sheet each time you open it.
04 / Read the shape
The list protects links. The cue sheet defines names.
Basic form is the list itself, generic over its values. Insert-after hands you a new handle. Move-after keeps an existing one. Remove hands back the value and retires the handle. No destination means “at the front.” Moving a node after itself, or after the node already before it, does nothing.
In the wild wraps it in CueSheet, which owns everything about
names: what counts as one, that repeats are allowed, that Find is exact and returns the
first match in current order. The list never sees a name rule. Notice that the call site
checks Find succeeded before inserting after its result, because “not found” must never
quietly turn into “insert at the front.”
A doubly linked list with private nodes and owner-checked handles. Find walks from the head. Insert, move, and remove act on retained handles. Optional traces record visits and changed link fields.
declare const handleType: unique symbol;
export type Handle<T> = Readonly<{ id: number; [handleType]: T }>;
export type Lookup<T> = { found: true; value: T } | { found: false };
export type LinkWrite = {
node: number | null;
field: 'head' | 'tail' | 'prev' | 'next';
from: number | null;
to: number | null;
};
export type Trace = { visits: number[]; writes: LinkWrite[] };
export type View<T> = {
head: number | null;
tail: number | null;
size: number;
nodes: { id: number; value: T; prev: number | null; next: number | null }[];
};
type Node<T> = { handle: Handle<T>; value: T; prev: Node<T> | null; next: Node<T> | null };
export class LinkedList<T> {
#head: Node<T> | null = null;
#tail: Node<T> | null = null;
#size = 0;
#nextId = 1;
#nodes = new WeakMap<Handle<T>, Node<T>>();
#trace: Trace = { visits: [], writes: [] };
constructor(private readonly captureTrace = false) {}
get size() {
return this.#size;
}
front(): Handle<T> | null {
return this.#head?.handle ?? null;
}
back(): Handle<T> | null {
return this.#tail?.handle ?? null;
}
has(handle: Handle<T>): boolean {
return this.#nodes.has(handle);
}
#begin() {
this.#trace = { visits: [], writes: [] };
}
#record(
node: Node<T> | null,
field: LinkWrite['field'],
from: Node<T> | null,
to: Node<T> | null
) {
if (this.captureTrace && from !== to)
this.#trace.writes.push({
node: node?.handle.id ?? null,
field,
from: from?.handle.id ?? null,
to: to?.handle.id ?? null
});
}
#end(field: 'head' | 'tail', to: Node<T> | null) {
this.#record(null, field, field === 'head' ? this.#head : this.#tail, to);
if (field === 'head') this.#head = to;
else this.#tail = to;
}
#link(node: Node<T>, field: 'prev' | 'next', to: Node<T> | null) {
this.#record(node, field, node[field], to);
node[field] = to;
}
#attach(node: Node<T>, after: Node<T> | null) {
const next = after ? after.next : this.#head;
this.#link(node, 'prev', after);
this.#link(node, 'next', next);
if (after) this.#link(after, 'next', node);
else this.#end('head', node);
if (next) this.#link(next, 'prev', node);
else this.#end('tail', node);
}
#detach(node: Node<T>) {
if (node.prev) this.#link(node.prev, 'next', node.next);
else this.#end('head', node.next);
if (node.next) this.#link(node.next, 'prev', node.prev);
else this.#end('tail', node.prev);
}
insertAfter(after: Handle<T> | null, value: T): Handle<T> | null {
this.#begin();
const anchor = after === null ? null : this.#nodes.get(after);
if (anchor === undefined) return null;
const handle = Object.freeze({ id: this.#nextId++ }) as Handle<T>;
const node: Node<T> = { handle, value, prev: null, next: null };
this.#nodes.set(handle, node);
this.#attach(node, anchor);
this.#size++;
return handle;
}
moveAfter(handle: Handle<T>, after: Handle<T> | null): 'moved' | 'unchanged' | 'invalid-handle' {
this.#begin();
const node = this.#nodes.get(handle);
const anchor = after === null ? null : this.#nodes.get(after);
if (!node || anchor === undefined) return 'invalid-handle';
if (node === anchor || node.prev === anchor) return 'unchanged';
this.#detach(node);
this.#attach(node, anchor);
return 'moved';
}
remove(handle: Handle<T>): Lookup<T> {
this.#begin();
const node = this.#nodes.get(handle);
if (!node) return { found: false };
this.#detach(node);
this.#link(node, 'prev', null);
this.#link(node, 'next', null);
this.#nodes.delete(handle);
this.#size--;
return { found: true, value: node.value };
}
find(matches: (value: T) => boolean): Handle<T> | null {
this.#begin();
for (let node = this.#head; node; node = node.next) {
if (this.captureTrace) this.#trace.visits.push(node.handle.id);
if (matches(node.value)) return node.handle;
}
return null;
}
// Metadata and outer sequences are independent; arbitrary payloads are not deep-cloned.
view(): View<T> {
const nodes: View<T>['nodes'] = [];
for (let node = this.#head; node; node = node.next)
nodes.push({
id: node.handle.id,
value: node.value,
prev: node.prev?.handle.id ?? null,
next: node.next?.handle.id ?? null
});
return {
head: this.#head?.handle.id ?? null,
tail: this.#tail?.handle.id ?? null,
size: this.#size,
nodes
};
}
reverseValues(): T[] {
const values: T[] = [];
for (let node = this.#tail; node; node = node.prev) values.push(node.value);
return values;
}
trace(): Trace {
return {
visits: [...this.#trace.visits],
writes: this.#trace.writes.map((write) => ({ ...write }))
};
}
} type Handle[T any] struct {
id int
node *node[T]
}
func (h *Handle[T]) ID() int { return h.id }
type node[T any] struct {
handle *Handle[T]
value T
prev, next *node[T]
owner *LinkedList[T]
}
type LinkWrite struct {
Node *int `json:"node"`
Field string `json:"field"`
From *int `json:"from"`
To *int `json:"to"`
}
type Trace struct {
Visits []int `json:"visits"`
Writes []LinkWrite `json:"writes"`
}
type NodeView[T any] struct {
ID int `json:"id"`
Value T `json:"value"`
Prev *int `json:"prev"`
Next *int `json:"next"`
}
type View[T any] struct {
Head *int `json:"head"`
Tail *int `json:"tail"`
Size int `json:"size"`
Nodes []NodeView[T] `json:"nodes"`
}
type LinkedList[T any] struct {
head, tail *node[T]
size, nextID int
captureTrace bool
trace Trace
}
func NewList[T any](captureTrace bool) *LinkedList[T] {
return &LinkedList[T]{captureTrace: captureTrace, trace: Trace{[]int{}, []LinkWrite{}}}
}
func (l *LinkedList[T]) Size() int { return l.size }
func (l *LinkedList[T]) Front() *Handle[T] {
if l.head == nil {
return nil
}
return l.head.handle
}
func (l *LinkedList[T]) Back() *Handle[T] {
if l.tail == nil {
return nil
}
return l.tail.handle
}
func (l *LinkedList[T]) Has(h *Handle[T]) bool {
return h != nil && h.node != nil && h.node.owner == l && h.node.handle == h
}
func nodeID[T any](n *node[T]) *int {
if n == nil {
return nil
}
id := n.handle.id
return &id
}
func (l *LinkedList[T]) begin() { l.trace = Trace{[]int{}, []LinkWrite{}} }
func (l *LinkedList[T]) record(n *node[T], field string, from, to *node[T]) {
if l.captureTrace && from != to {
l.trace.Writes = append(l.trace.Writes, LinkWrite{nodeID(n), field, nodeID(from), nodeID(to)})
}
}
func (l *LinkedList[T]) end(field string, to *node[T]) {
if field == "head" {
l.record(nil, field, l.head, to)
l.head = to
} else {
l.record(nil, field, l.tail, to)
l.tail = to
}
}
func (l *LinkedList[T]) link(n *node[T], field string, to *node[T]) {
if field == "prev" {
l.record(n, field, n.prev, to)
n.prev = to
} else {
l.record(n, field, n.next, to)
n.next = to
}
}
func (l *LinkedList[T]) attach(n, after *node[T]) {
next := l.head
if after != nil {
next = after.next
}
l.link(n, "prev", after)
l.link(n, "next", next)
if after != nil {
l.link(after, "next", n)
} else {
l.end("head", n)
}
if next != nil {
l.link(next, "prev", n)
} else {
l.end("tail", n)
}
}
func (l *LinkedList[T]) detach(n *node[T]) {
if n.prev != nil {
l.link(n.prev, "next", n.next)
} else {
l.end("head", n.next)
}
if n.next != nil {
l.link(n.next, "prev", n.prev)
} else {
l.end("tail", n.prev)
}
}
func (l *LinkedList[T]) InsertAfter(after *Handle[T], value T) *Handle[T] {
l.begin()
var anchor *node[T]
if after != nil {
if !l.Has(after) {
return nil
}
anchor = after.node
}
l.nextID++
h := &Handle[T]{id: l.nextID}
n := &node[T]{handle: h, value: value, owner: l}
h.node = n
l.attach(n, anchor)
l.size++
return h
}
func (l *LinkedList[T]) MoveAfter(h, after *Handle[T]) string {
l.begin()
if !l.Has(h) || (after != nil && !l.Has(after)) {
return "invalid-handle"
}
var anchor *node[T]
if after != nil {
anchor = after.node
}
n := h.node
if n == anchor || n.prev == anchor {
return "unchanged"
}
l.detach(n)
l.attach(n, anchor)
return "moved"
}
func (l *LinkedList[T]) Remove(h *Handle[T]) (T, bool) {
l.begin()
var zero T
if !l.Has(h) {
return zero, false
}
n := h.node
l.detach(n)
l.link(n, "prev", nil)
l.link(n, "next", nil)
value := n.value
n.value = zero
n.owner = nil
l.size--
return value, true
}
func (l *LinkedList[T]) Find(matches func(T) bool) *Handle[T] {
l.begin()
for n := l.head; n != nil; n = n.next {
if l.captureTrace {
l.trace.Visits = append(l.trace.Visits, n.handle.id)
}
if matches(n.value) {
return n.handle
}
}
return nil
}
func (l *LinkedList[T]) View() View[T] {
view := View[T]{nodeID(l.head), nodeID(l.tail), l.size, []NodeView[T]{}}
for n := l.head; n != nil; n = n.next {
view.Nodes = append(view.Nodes, NodeView[T]{n.handle.id, n.value, nodeID(n.prev), nodeID(n.next)})
}
return view
}
func (l *LinkedList[T]) ReverseValues() []T {
values := []T{}
for n := l.tail; n != nil; n = n.prev {
values = append(values, n.value)
}
return values
}
func copyID(id *int) *int {
if id == nil {
return nil
}
value := *id
return &value
}
func copyTrace(trace Trace) Trace {
out := Trace{append([]int{}, trace.Visits...), []LinkWrite{}}
for _, w := range trace.Writes {
out.Writes = append(out.Writes, LinkWrite{copyID(w.Node), w.Field, copyID(w.From), copyID(w.To)})
}
return out
}
func (l *LinkedList[T]) Trace() Trace { return copyTrace(l.trace) } Reading the TypeScriptAn opaque handle, checked by identity
The handle is a branded type, and a private WeakMap checks the actual token
object at runtime, so building { id: 4 } yourself does not get you
cue #4. Each list has its own lookup. Removal deletes the entry and clears the node’s links.
The tagged removal result tells absence apart from a stored undefined.
Inspection copies link metadata and the outer sequence; objects you store stay shared. A
Find predicate must not mutate the list mid-walk.
Reading the GoPrivate node pointers and one owner
Each node stores previous, next, and its owner. A handle must belong to this list and still be live. Use the constructor’s pointer and do not copy a list value afterwards. Removal clears links, ownership, and the value, so a stale handle someone kept cannot keep the rest of the sheet alive.
(value, ok) tells a removed value from absence. The optional trace copies its
metadata before returning it, so inspecting a result cannot rewrite the links.
05 / Try a decision
A cue name is not a node handle.
Same editor, a cue sheet a thousand entries long. Someone tells you “inserting into a linked list is constant time.” Before you agree, look at what the caller is holding.
06 / Follow the cost
Finding a node and relinking it are separate operations.
Here is every operation at a glance, with n live nodes. Look down the time column and you can see the split this whole lesson has been circling: finding is linear, and changing what you already hold is constant.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Read head, tail, or size | O(1) | O(1) | The list stores all three. |
| Find a value | O(n) | O(1) | Walks from the head until it finds a match or runs out. |
| Reach the kth node | O(k) | O(1) | Not a method in this lesson’s code, because a linked node has no index. Any positional access walks k links. |
| Insert after a held node | O(1) | O(1) | You already hold the handle. At most 4 link fields change, plus one new node. |
| Move a held node | O(1) | O(1) | At most 6 link fields change. The value itself is never copied. |
| Remove a held node | O(1) | O(1) | Needs the previous link, which is why this list is doubly linked. |
| Copy for a view | O(n) | O(n) | Walks every live node and copies its links and its value. For objects in TypeScript, that is a copy of the reference. |
| Hold n nodes | — | O(n) | Two links per node on top of each value. |
Find is a walk. With n live cues it may visit all n before it finds a match or gives up. Reaching “the 40th cue” is a walk too; a linked node has no index. Comparing big payloads makes every visit dearer.
Relinking is not a walk. Once you hold valid handles, this doubly linked list changes a fixed handful of fields. The story’s insertion changed four: the new node’s two links and one link on each neighbor. The move changed six: unhook from the old neighbors, hook in at the destination.
“Changed” counts fields whose target actually differs, head and tail included; a no-op move changes zero. Left out of these counts: validation, bookkeeping, allocation, payload cleanup, tracing, snapshots, and drawing. A small link count is a promise about the relink, not about your whole operation.
Previous links cost space. A singly linked list carries less, but a node on its own cannot tell you who precedes it, so removing an interior node needs that predecessor already in hand or another walk to find it. The one shortcut, copying the next node’s value over it and removing that one instead, fails on the tail and silently changes which node is which, the very identity this lesson depends on.
07 / Give it a real job
Let the rehearsal editor own the list.
In the rehearsal editor, each row keeps the handle it got when its cue was inserted and uses it for later moves and removal. The raw links stay private, so every edit goes through methods that keep both directions consistent. A row that was removed and fires a late callback is rejected instead of editing someone else’s cue.
That is all this editor is about: order and handle validity. Running the show, saving revisions, several stage managers at once, and talking to the lighting desk each need contracts of their own.
And reach for the standard collection when it gives you the contract you need. Go’s container/list is a doubly linked list with element pointers and move operations. Rust’s collections guide reserves LinkedList for a few cases, including when you are “absolutely
certain” you want one, and notes that Vec and VecDeque are generally
faster. The asymptotic label alone does not pick the storage.
Build UIs?Your hooks are in one of these, and one day you will need to own one.
Where it already is in your components
React stores a component’s hooks as a chain of nodes on its fiber: one for each useState, useRef, useMemo, or useEffect call, each linked
to the next. useContext is the exception; it reads context without adding a
node, though React still asks you to call it at the top level. A hook has no name. The
only way React finds your useState on the next render is by walking the chain in call order. Wrap a
hook in an if and on some render the walk either runs out of nodes, and React
throws, or hands a hook the wrong saved state. “Only call hooks at the top level” is not a
style rule. It is the walk, and you can read it in React’s ReactFiberHooks.js.
Switch to Svelte and the same shape is legal. Svelte keeps a component’s effects in a
linked list too, through the first, last, next, and prev links in its effects runtime. But it runs the component’s script once and never walks that list by position to match
calls on a later update. So state and effects that only make sense once the user has
loaded can live in a child component inside {#if user}, created when the
branch opens. Same structure, and no order to get wrong.
When you have to own it
A map or canvas app keeps rendered tiles around so panning back is instant, but not all of them: a few hundred, most recent first. Every hit moves a tile to the front. When the cache is full, the least recent one goes. With an array that move is a splice, which shifts every entry after it, and an unshift, which shifts every entry, on every hit.
The version that stays small is a map from key to node plus a doubly linked list of the nodes. The map is the handle, so a hit never searches. Moving to the front is a handful of link changes. Eviction is whatever the tail points at. That composition is the LRU cache, and this is the moment you would reach for it.
A profile component with a hook after an early return. React matches hooks by call order and throws; Svelte runs the script once, so a child inside {#if user} can own that state and its effect.
import { useEffect, useState } from 'react';
import { fetchUser, type User } from './api';
export function Profile({ userId }: { userId: string }) {
// Hook 1. React keeps a node for it on this component's fiber.
const [user, setUser] = useState<User | null>(null);
// Hook 2, linked after hook 1. On every render React walks the
// chain in call order to hand each hook its saved state back.
useEffect(() => {
let ignore = false; // a newer userId wins; drop the stale response
fetchUser(userId).then((loaded) => {
if (!ignore) setUser(loaded);
});
return () => {
ignore = true;
};
}, [userId]);
if (!user) return <p>Loading…</p>;
// Hook 3, but only once user has loaded. The first render walked two
// nodes; this render asks for a third, and React stops with
// "Rendered more hooks than during the previous render."
// Skip a hook and call a different one in its place, and the walk
// hands that hook the wrong saved state instead.
// That is the whole reason hooks cannot be conditional.
const [editing, setEditing] = useState(false);
return <button onClick={() => setEditing(!editing)}>{user.name}</button>;
}
08 / Make the call
Look at what the caller already knows.
A linked structure earns its place when callers repeatedly edit interior nodes they already hold, and those handles must survive. If callers mostly append, scan, sort, or ask for the nth item, start with an array. If they only touch the ends, a queue or deque says so more clearly.
When you need both lookup by key and frequent movement within an order, a map and a linked list work together. That is the LRU cache, where recency and eviction bring rules of their own.
09 / Take the idea with you
Explain the reorder without saying “linked list.”
“Once I have the cue in hand, moving it means changing the neighbors it points at, not shifting everything after it.” That sentence describes the contract. The name is a shorthand for it afterwards.
Before moving on, note three things: why a name and a handle are different, which links
changed when Stage spot moved, and where the search that found it actually happened. Then
open a hook-heavy component you own and count its useState, useRef, useMemo, and useEffect calls. That is its chain.
Connections to follow nextRelated lessons
- Dynamic array reaches a position directly and makes room when storage fills.
- Stack restricts the next operation to one end.
- LRU cache pairs this list with a hash map, so any entry is one lookup and a few link changes away.