如何用 jstash 保留 localStorage 並加入按用戶同步
Keep localStorage, add per-user sync with jstash
jstash 示範如何保留 localStorage 作本機及離線副本,並在用戶登入後同步至按用戶隔離的雲端儲存。範例按筆記 ID 合併本機與雲端資料,並在帳戶切換時清除前一用戶的本機副本。一般儲存仍可能造成跨裝置覆寫,文章建議以 ETag 和 If-Match 偵測衝突;若要同時刪除雲端及本機資料,需清除本機副本,否則下次登入會再次上傳。
Your app already saves to localStorage. It loads quickly, survives a refresh and works offline. Then someone signs in on another device and their notes are missing.
Keep localStorage as the fast local copy and offline fallback. When someone signs in, sync their data to per-user cloud storage.
The request is straightforward. Choosing whose data to upload, merge or clear takes more care.
Set up per-user storage
jstash Apps stores private JSON documents per signed-in user. The browser sends the user's existing login token from Clerk, Google, Supabase Auth or Firebase Auth. jstash checks it, so each person can only read and write their own documents. There is no jstash API key in the page.
In the dashboard at app.jstash.app, choose New app, pick your login and tick Allow localhost to test locally.
The document endpoint is:
https://api.jstash.app/v1/apps/{appId}/me/{name}
PUT replaces the whole document with any JSON. GET returns it, or 404 DOC_NOT_FOUND if nothing is saved.
The Free plan includes 1 app, up to 100 users, documents up to 100 KB and 200 documents per user, with no card. Write limits are 100 saves per user per day and 500 per app per day across all users, plus a per-minute rate limit of 60 saves. The Free plan also has a monthly allowance of 5,000 writes and 50,000 reads per jstash account; these monthly allowances will not be enforced before 17 November 2026. Pro (US$5 a month) raises the limits.
Clerk was tested end to end with a Clerk development instance, and Google sign-in was tested end to end. Supabase Auth and Firebase Auth have so far only been tested with simulated tokens.
Decide what happens at sign-in
For a notes list, use this rule:
- If the cloud has nothing, upload local notes.
- Otherwise, load cloud notes and merge by id. If local changes have not been saved to the cloud, local notes win when ids match. Otherwise, cloud notes win.
For other data, choose an explicit merge or replace rule.
Store the user id beside the local data too. On a shared browser, a different user must never inherit the previous user's notes. Clear that local copy before loading theirs.
This example assumes each note has a unique id. It uses the starter helpers save(name, value) and load(name), plus currentUserId() from your auth library.
The starter load returns null for 404 DOC_NOT_FOUND and throws for other failures. Keep editing disabled until sign-in sync finishes, and handle rejected promises in your UI.
let timer;
let session = 0;
let ready = false;
let queue = Promise.resolve();
function queueSave(userId, run, json) {
queue = queue.catch(() => {}).then(async () => {
if (run !== session || currentUserId() !== userId) return;
await save('notes', JSON.parse(json));
if (
run === session &&
currentUserId() === userId &&
localStorage.getItem('notes') === json
) {
localStorage.removeItem('notes_dirty');
}
});
return queue;
}
async function syncOnSignIn(userId) {
clearTimeout(timer);
const run = ++session;
ready = false;
// Let any request already sent finish before loading cloud notes.
await queue.catch(() => {});
if (run !== session || currentUserId() !== userId) return;
const owner = localStorage.getItem('notes_owner');
if (owner !== null && owner !== userId) {
localStorage.removeItem('notes');
localStorage.removeItem('notes_dirty');
}
localStorage.setItem('notes_owner', userId);
const local = JSON.parse(localStorage.getItem('notes') || '[]');
const dirty = localStorage.getItem('notes_dirty') === 'true';
const cloud = await load('notes');
if (run !== session || currentUserId() !== userId) return;
const notes = cloud === null
? local
: [...new Map(
(dirty ? [...cloud, ...local] : [...local, ...cloud])
.map(note => [note.id, note])
).values()];
const json = JSON.stringify(notes);
localStorage.setItem('notes', json);
localStorage.setItem('notes_dirty', 'true');
await queueSave(userId, run, json);
if (run === session && currentUserId() === userId) {
ready = true;
}
}
function flushSave() {
clearTimeout(timer);
const userId = currentUserId();
if (!userId || !ready) return;
if (localStorage.getItem('notes_dirty') !== 'true') return;
const json = localStorage.getItem('notes');
if (json === null) return;
return queueSave(userId, session, json).catch(error => {
console.error('Cloud save failed; local notes remain.', error);
});
}
function saveSoon(notes) {
const userId = currentUserId();
if (userId && !ready) return;
if (!userId && localStorage.getItem('notes_owner') !== null) return;
const json = JSON.stringify(notes);
localStorage.setItem('notes', json);
localStorage.setItem('notes_dirty', 'true');
clearTimeout(timer);
if (!userId) return;
localStorage.setItem('notes_owner', userId);
timer = setTimeout(flushSave, 2000);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushSave();
}
});
function onSignOut() {
clearTimeout(timer);
++session;
ready = false;
}
Call syncOnSignIn(userId) after sign-in, when a saved session is restored on page load, and on account changes. If it rejects, show an error and offer a retry that calls it again. Call onSignOut() as sign-out begins.
An unowned local list is treated as this device's existing data and claimed by the first account that signs in. That is a product decision: explain it if people may share the browser.
The owner stays beside the local notes after sign-out, so the next sign-in can distinguish returning users from different users. Owned notes remain in localStorage while signed out. Your app must hide them and disable editing whenever notes_owner is set and nobody is signed in, including immediately when sign-out begins. The guard in saveSoon prevents signed-out edits to owned notes; it does not hide them in your UI. Clearing a pending timeout cannot undo a request already sent.
Save after edits
Call saveSoon(notes) when notes change. When editing is allowed, it writes localStorage immediately and marks the notes as unsaved, then waits about 2 seconds before saving to the cloud if someone is signed in. Another edit restarts that wait. When the page becomes hidden, the visibilitychange handler queues any unsaved notes without waiting for the debounce. A request started then is not guaranteed to finish before the page closes.
Debounce saves instead of saving on every keystroke. Never save on a timer that runs regardless of edits. The longer debounce reduces saves, but it does not guarantee that a notes app stays within the Free write limits. Sign-in syncs and saves when the page is hidden count too.
The promise queue runs cloud saves one at a time. After each save, it clears the dirty flag only if the session still matches and the saved JSON still matches localStorage. Newer local edits stay marked as unsaved. Sign-in sync also waits for earlier saves to finish before loading cloud notes.
If a cloud request fails, including because a write limit has been reached, local notes remain available to their owner and stay marked as unsaved. Show that sync failed and provide a retry action. This snippet queues saves but does not retry failed requests automatically. A retry can call flushSave() once the limit allows another save.
localStorage reads, parsing and writes can also fail. Handle those errors in your app.
Protect changes from another device
A debounce does not prevent two devices from overwriting each other. The sample uses ordinary saves, so it still has that risk.
jstash supports conditional writes with ETags. GET returns an ETag header. Send that value with PUT using If-Match. If someone else saved first, the request returns 412 ETAG_MISMATCH.
Use that response to fetch the latest document and resolve the conflict before trying again. The list merge above cannot distinguish a deleted note from one that should be restored.
Let people delete their cloud data
Treat the cloud copy as the user's data. Give people a way to delete it: DELETE …/me removes everything that person saved. Clear the local copy too if your deletion action is meant to remove both; otherwise the next sign-in sync uploads them again.
jstash keeps no old versions.
Start with the working examples (MIT) and the docs.
I'm from jstash. AI helped draft this post.
來源:DEV Community · dev.to