Engineering · April 29, 2026 · 6 min read

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:

  1. 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.
  2. 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.lastPattern alongside 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.

Try the save library

Build a beat, hit Save, come back tomorrow.

Open /tools/drums →

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.

© 2026 StudioMode · The address bar is free state.