weavedev/redux-local-storage

View on GitHub
lib/ReduxLocalStorage.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { InternalReducer, Reduxable } from '@weavedev/reduxable';
import { Action } from 'redux';

export const defaultStateSourceMarker: unique symbol = Symbol('defaultStateSourceMarker');

type TypeFromReduxable<R extends Reduxable<any, any, any[]>> = R['actionMap'][keyof R['actionMap']]['type'];

type TypesFromReduxable<R extends Reduxable<any, any, any[]>> = TypeFromReduxable<R>[];

interface ReadWriteTransformer<S, M> {
    read(s: M): S;
    write(s: S): M;
}

interface ReduxLocalStorageOptions<R extends Reduxable<any, any, any[]>, S> {
    transform?: ReadWriteTransformer<R['state'], S>;
    triggers?: TypesFromReduxable<R>;
}

/**
 * Typed redux local storage class that mirrors state to and from JavaScript's `window.localStorage`.
 */
export class ReduxLocalStorage<
    R extends Reduxable<any, any, any[]>,
    M = R['state']
> extends Reduxable<R['state'], R['actionMap'], Parameters<R['run']>> {
    public readonly actionTypeMap: R['actionTypeMap'];

    private readonly reduxable: R;
    private readonly target: string;
    private readonly transform: ReadWriteTransformer<R['state'], M>;
    private readonly triggers?: TypesFromReduxable<R>;

    constructor(reduxable: R, target: string, options?: ReduxLocalStorageOptions<R, M>) {
        super();
        this.reduxable = reduxable;
        this.target = target;
        this.actionTypeMap = reduxable.actionTypeMap;

        this.transform = options && options.transform ? options.transform : {
            read: (s: M): R['state'] => <R['state']><unknown>s,
            write: (s: R['state']): M => <M><unknown>s,
        };
        this.triggers = options ? options.triggers : undefined;
    }

    public get actionMap(): R['actionMap'] {
        throw new Error('ReduxLocalStorage.actionMap should only be used as a TypeScript type provider (typeof .actionMap)');
    }

    /**
     * Passthrough default state with marker added to identify defaultState in the reducer.
     */
    public get defaultState(): R['state'] {
        return {
            ...(<R['state']>this.reduxable.defaultState),
            [defaultStateSourceMarker]: true,
        };
    }

    protected get internalReducer(): InternalReducer<R['state']> {
        const context: ReduxLocalStorage<R> = this;

        return (s: R['state'], a: Action): R['state'] => {
            let inputState: R['state'] = s;

            // Check if we're building from the defaultState
            if (inputState[defaultStateSourceMarker]) {
                let useDefaultState: boolean = true;

                // Try to load from localStorage
                const storedStateString: string | null = window.localStorage.getItem(context.target);
                if (storedStateString !== null) {
                    try {
                        // Unmarshal string
                        inputState = context.transform.read(<M>JSON.parse(storedStateString));

                        // Use saved state instead of default state
                        useDefaultState = false;
                    } catch (e) {
                        console.warn('Could not unmarshal state from localStorage', e);
                    }
                }

                // Fallback to default state and clean up marker
                if (useDefaultState) {
                    // Clone object
                    inputState = { ...s };

                    // Remove marker
                    delete inputState[defaultStateSourceMarker]; // tslint:disable-line:no-dynamic-delete
                }

                // Setup reducer with initial state
                return <R['state']>context.reduxable.reducer(inputState, a);
            }

            // Run reducer
            const nextState: R['state'] = <R['state']>context.reduxable.reducer(s, a);

            // If a different state is returned check if we need to save to localStorage
            // tslint:disable-next-line: no-unsafe-any
            if (nextState !== s && (context.triggers === undefined || context.triggers.indexOf(a.type) >= 0)) {
                try {
                    window.localStorage.setItem(context.target, JSON.stringify(context.transform.write(nextState)));
                } catch (e) {
                    console.warn('Could not marshal state to localStorage', e);
                }
            }

            return nextState;
        };
    }

    // Passthrough saga
    public get saga(): R['saga'] {
        return this.reduxable.saga;
    }

    // Passthrough run
    public run(...i: Parameters<R['run']>): ReturnType<R['run']> {
        return <ReturnType<R['run']>>this.reduxable.run(...i);
    }

    // Passthrough runSaga
    public get runSaga(): R['runSaga'] {
        return this.reduxable.runSaga;
    }
}