Round-trippable URLs: when saving is sharing
The drum sandbox and the piano roll both got "Save" buttons this week. Loading a saved entry feels like opening a tab — every step, chord, kit, and BPM is there. The implementation took about 30 lines per tool because save/load is a strict subset of share/load when the URL hash is the canonical state. One serializer, two surfaces.
The setup
Both tools have always had Share buttons. Click Share on the drum sandbox and you get a URL like:
https://studiomode.app/tools/drums#bpm=90&p=1010101010101010-...&prog=pop&bass=walking&kit=eight08
The hash encodes everything: the 16-step pattern for each of
the 4 drum tracks, the BPM, the chord progression id, the
bass mode, and the drum kit. Open the link in a new tab and
the page rebuilds from the hash. Same on the piano roll, where
the hash is just #notes=C,E,G,Bb.
That mechanism is older than the Save button. It's been the sharing primitive since the tools shipped. The serializer pair is two short functions:
function patternToHash() {
const rows = TRACKS.map((t) => pattern[t.key].map((b) => b ? '1' : '0').join(''));
return `bpm=${bpm}&p=${rows.join('-')}&prog=${activeProgId}&bass=${bassMode}&kit=${activeKit}`;
}
function hashToPattern() {
const h = location.hash.replace(/^#/, '');
// ...regex-extract each segment, validate, mutate state...
}
The realisation
A "save" feature normally means: serialize the current state to some persistent format, store it. A "load" feature means: pick from the stored list, deserialize, restore the state. Two functions in each direction.
But — Share already does the serialization. The hash format
is the persistent format. Saving doesn't need a new
serializer; it just needs to call patternToHash()
and stash the result in localStorage.
saveBtn.addEventListener('click', () => {
const name = prompt('Name this pattern:', '');
if (!name) return;
const arr = readSaved();
arr.unshift({ name, hash: patternToHash(), savedAt: Date.now() });
writeSaved(arr);
renderSavedPatterns();
});
Loading is the same trick in reverse. Don't write a deserializer
— just feed the saved hash through hashToPattern(),
the function that already exists for URL loads:
// Click handler on a saved-pattern pill:
location.hash = item.hash;
hashToPattern(); // existing function — no new code path
renderGrid();
renderKitRow();
renderChordProgs();
renderBassRow();
Save is just the share URL stored locally. Load is just open-tab with hash already in place. Same code path, two surfaces.
What this buys you
The obvious win is line count — about 30 lines of glue per tool instead of a parallel persistence-format design. But the compounding wins are bigger:
Forwards-compatibility falls out for free.
When we added the chord-progression overlay (Round 107), we
extended patternToHash() to include
&prog=.... Every existing share URL still works
(the missing segment defaults to "none"). Every existing saved
pattern still works. We didn't have to migrate localStorage.
The hash is the schema; the parser is permissive.
Sharing your saved pattern is one click. Click Save → name it → it's in your library. Want to send it to a producer? The Share button has been right there the whole time. Click it, paste the URL. The roundtrip — your browser → URL → their browser → same state — works because the format is the same.
Debugging is trivial. Every state is a string. Every string is a URL. If a saved pattern doesn't load right, open the URL in a private window and the bug reproduces. No "what was in localStorage when this happened" forensics.
The hidden constraint
This pattern only works if the URL hash actually is canonical state. Two failure modes:
- Some state isn't in the hash. If you forget to encode the drum kit in the hash but you did persist it in
activeKit, save will round-trip correctly within the session but fail across reloads. Discipline: the hash must serialize all state that affects what the user sees or hears. - Some state is in the hash but state is also stored independently. Now you have two sources of truth and no enforcer that they agree. Anti-pattern: don't keep "current pattern" in
localStorage.lastPatternalongside the hash. Just use the hash, let the user navigate.
In practice the second case bites more than the first. It's tempting to "remember the user's last session" by writing state to localStorage on every change. But then on next load, the hash and the localStorage entry can disagree — and the restoration logic has to pick. Choose the URL.
The 30-line spec
Here's the entire save-list contract on /tools/drums, after taking out the rendering code:
const SAVED_KEY = 'beatvault.drumPatterns.v1';
const SAVED_MAX = 12;
function readSaved() {
try {
const arr = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
function writeSaved(arr) {
try { localStorage.setItem(SAVED_KEY, JSON.stringify(arr.slice(0, SAVED_MAX))); } catch {}
}
// Save: existing serializer + name + timestamp
function saveCurrent(name) {
const arr = readSaved();
const i = arr.findIndex((p) => p.name === name);
const entry = { name, hash: patternToHash(), savedAt: Date.now() };
if (i >= 0) arr[i] = entry; else arr.unshift(entry);
writeSaved(arr);
}
// Load: existing deserializer
function loadSaved(item) {
location.hash = item.hash;
hashToPattern();
rerenderEverything();
}
That's it. Same shape on the piano roll. Add a third sandbox
tomorrow and the recipe is identical: serializer
patternToHash, deserializer
hashToPattern, then one Save button and one
saved-pills row.
Why I'm writing this down
This isn't a new idea. Bookmarklets, deep links, undo/redo via URL — all the same family. But it's the kind of thing that gets re-invented every few months by someone reaching for Redux when they could've reached for the address bar. The browser already had a free state container. I'm just using it.