Skip to content

Instantly share code, notes, and snippets.

@aguynamedben
Created August 25, 2020 16:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aguynamedben/3bef9af36c2a257aa2f0c77e3f046d01 to your computer and use it in GitHub Desktop.
Save aguynamedben/3bef9af36c2a257aa2f0c77e3f046d01 to your computer and use it in GitHub Desktop.
Persist Redux to localStorage, with migrations (i.e. migrate off redux-persist)
// @flow
// Inspired by https://medium.com/@jrcreencia/persisting-redux-state-to-local-storage-f81eb0b90e7e
const log = console;
let firstLoad = true;
export function loadState() {
try {
const serializedState = localStorage.getItem('state');
if (!serializedState) {
return undefined;
}
const state = JSON.parse(serializedState);
if (firstLoad) {
log.debug(`loaded state from localStorage for the first time`, state);
firstLoad = false;
} else {
log.warn(`reloaded state from localStorage`, state);
}
return state;
} catch (error) {
log.error(`couldn't load state from localStorage: ${error.toString()}`);
return undefined;
}
}
// TODO: use type for state, when we use createSlice
export function saveState(state: any) {
try {
const serializedState = JSON.stringify(state);
localStorage.setItem('state', serializedState);
log.debug(`saved state to localStorage`, state);
} catch (error) {
// ignore write errors
log.error(`couldn't save state to localStorage: ${error.toString()}`);
}
}
// You only need this file if you're migrating from redux-persist to this solution
import * as logger from 'shared/logger';
import { saveState } from 'foreground/localStorage';
const log = logger.make('migrateReduxPersist');
export function migrateReduxPersistIfNecessary() {
const serializedReduxPersistState = localStorage.getItem('persist:root');
const serializedState = localStorage.getItem('state');
if (serializedState !== null) {
log.info(`not moving off redux-persist: 'state' already present in localStorage`);
return;
}
if (serializedReduxPersistState == null) {
log.info(`not moving off redux-persist: 'persist:root' not present in localStorage`);
return;
}
log.info(`moving off redux-persist`);
let state;
try {
state = deserializeReduxPersistState(serializedReduxPersistState);
} catch (error) {
// this should never happen, but if it does it's better to reset redux state
// than leave it for-sure broken
console.warn(
`Couldn't move off redux-persist due to problem deserializing 'persist:root' in localStorage. This will reset the user's redux state.`,
error,
);
return;
}
localStorage.setItem('stateVersion', state._persist.version);
delete state._persist;
saveState(state);
localStorage.removeItem('persist:root');
log.info(`done moving off redux-persist`);
}
// redux-persist serialization calls JSON.parses at each level of state
// mimic https://github.com/rt2zz/redux-persist/blob/c3841f2fc56adf30a87cd61f7d2315146048079e/src/getStoredState.js
function deserializeReduxPersistState(serialized) {
const transforms = [];
try {
const state = {};
const rawState = deserialize(serialized);
Object.keys(rawState).forEach((key) => {
state[key] = transforms.reduceRight((subState, transformer) => {
return transformer.out(subState, key, rawState);
}, deserialize(rawState[key]));
});
return state;
} catch (error) {
console.warn(`error restoring redux-persist data ${serialized}`, error);
throw error;
}
}
function deserialize(serial) {
return JSON.parse(serial);
}
import preduce from 'p-reduce';
import * as logger from 'shared/logger';
import { loadState, saveState } from 'foreground/localStorage';
import { migrations, targetVersion } from './migrations';
const log = logger.make('reduxMigrations');
export async function loadAndMigrateState() {
const state = loadState();
const stateVersion = parseInt(localStorage.getItem('stateVersion'), 10);
if (!state || Number.isNaN(stateVersion)) {
log.info(`state or stateVersion not present in localStorage, state will be new`);
saveState(undefined);
localStorage.setItem('stateVersion', targetVersion);
return undefined;
}
log.info(`stateVersion: ${stateVersion}, targetVersion: ${targetVersion}`);
if (stateVersion === targetVersion) {
log.info(`already caught up to migration ${targetVersion}, no migrations to run`);
return state;
}
if (stateVersion > targetVersion) {
log.warn(`downgrading version is not supported`);
return state;
}
const migrationIds = Object.keys(migrations)
.filter((migrationId) => (
migrationId <= targetVersion && migrationId > stateVersion
))
.sort((a, b) => a - b);
log.info(`running migrations ${migrationIds.join(', ')}`);
return preduce(migrationIds, applyMigration, state);
}
async function applyMigration(state, version) {
log.info(`running migration ${version}`);
// doing this lets you run non-async migrations too
return Promise.resolve(migrations[version](state))
.then((nextState) => {
log.info(`successfully ran migration ${version}`);
saveState(nextState);
localStorage.setItem('stateVersion', version);
return nextState;
})
.catch((error) => {
log.info(`error running migration ${version}`, error.message);
return Promise.reject(error);
});
}
// An example migration
import log from 'shared/logger';
/**
* No-op for the first migration... because okay!
*/
export default async function migration1(state) {
log.debug(`migration1 called with state`, state);
return state;
}
import migration1 from './migration1';
import migration2 from './migration2';
import migration3 from './migration3';
import migration4 from './migration4';
import migration5 from './migration5';
import migration6 from './migration6';
import migration7 from './migration7';
import migration8 from './migration8';
import migration9 from './migration9';
import migration10 from './migration10';
import migration11 from './migration11';
import migration12 from './migration12';
import migration13 from './migration13';
import migration14 from './migration14';
import migration15 from './migration15';
import migration16 from './migration16';
import migration17 from './migration17';
import migration18 from './migration18';
import migration19 from './migration19';
import migration20 from './migration20';
import migration21 from './migration21';
import migration22 from './migration22';
import migration23 from './migration23';
import migration24 from './migration24';
import migration25 from './migration25';
import migration26 from './migration26';
import migration27 from './migration27';
// Update this when you add a new migration!
export const targetVersion = 27;
export const migrations = {
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
8: migration8,
9: migration9,
10: migration10,
11: migration11,
12: migration12,
13: migration13,
14: migration14,
15: migration15,
16: migration16,
17: migration17,
18: migration18,
19: migration19,
20: migration20,
21: migration21,
22: migration22,
23: migration23,
24: migration24,
25: migration25,
26: migration26,
27: migration27,
};
// USAGE
// in store.js...
migrateReduxPersistIfNecessary();
const state = await loadAndMigrateState();
const store = createStore(rootReducer, state, enhancer);
store.subscribe(_.throttle(() => {
saveState(store.getState());
}, 1000));
@aguynamedben
Copy link
Author

Note: Where there's a hyphen (-) in a filename (i.e. reduxMigrations-migrations.js) the hyphen should be a /. Gist doesn't allow directories in Gist file paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment