React/Preact hook: usePersistedStore

Published on 11 September 2020 1 minute read (31 words)

Persist any data in your application in localStorage between tabs and windows in real time via this great hook I wrote.

Implementation

You can find the useEventListener hook used in this snippet here.

usePersistedStore.hook.ts

// React
import { useRef, useState, useEffect } from 'react';

// Preact
import { useRef, useState, useEffect } from 'preact/hooks';

import { useEventListener } from './useEventListener.hook';

import { dataPersist, PersistedCallback } from './dataPersist.helper';
import { isDifferent } from './isDifferent.helper';
import storage from './storage.helper';

export function usePersistedStore<T>(key: string, initialValue: T) {
    const globalState = useRef<PersistedCallback | null>(null);

    const [storeState, setStoreState] = useState(() =>
        storage.get(key, initialValue)
    );

    // Automatically update store state on storage events
    useEventListener('storage', (event: StorageEvent) => {
        if (event.key === key && event.newValue) {
            try {
                const newStoreState = JSON.parse(event.newValue);

                if (isDifferent(newStoreState, storeState)) {
                    setStoreState(newStoreState);
                }
            } catch (e) {
                console.error(e);
            }
        }
    });

    useEffect(() => {
        // Subscribe to global persistance changes on mount
        globalState.current = dataPersist(key, setStoreState, initialValue);

        return (): void => globalState.current?.unsubscribe();
    }, []);

    useEffect(() => {
        storage.set(key, storeState);

        // Tell everybody else this store state has changed
        // (so they can update via their subscription)
        globalState.current?.emit(storeState);
    }, [storeState]);

    return [storeState, setStoreState];
}

dataPersist.helper.ts

const persistance: {
    [persistanceKey: string]: { callbacks: Function[]; value: any };
} = {};

export interface PersistedCallback {
    unsubscribe: () => void;
    emit: (value: any) => void;
}

export function dataPersist(
    key: string,
    callback: Function,
    initialValue?: any
): PersistedCallback {
    if (!persistance[key]) {
        persistance[key] = { callbacks: [], value: initialValue };
    }

    persistance[key].callbacks.push(callback);

    return {
        unsubscribe(): void {
            const callbacks = persistance[key].callbacks;
            const index = callbacks.indexOf(callback);

            if (index !== -1) {
                callbacks.splice(index, 1);
            }
        },

        emit(value: any): void {
            if (isDifferent(value, persistance[key].value)) {
                persistance[key].value = value;

                let iterator = persistance[key].callbacks.length;

                while (iterator--) {
                    const cb = persistance[key].callbacks[iterator];

                    if (cb !== callback) {
                        cb(value);
                    }
                }
            }
        }
    };
}

isDifferent.helper.ts

// Compares any two values of any kind and tells you if they're different
export function isDifferent(value: any, other: any): boolean {
    // Values are of differing types
    if (typeof value !== typeof other) {
        return true;
    }

    // Values are not objects or arrays (primitives)
    if (typeof value !== 'object') {
        return value !== other;
    }

    // Values are both arrays
    if (value instanceof Array && other instanceof Array) {
        // Values are arrays of different length
        if (value.length !== other.length) {
            return true;
        }

        // Comparing array values
        let iterator = value.length;

        while (iterator--) {
            if (isDifferent(value[iterator], other[iterator])) {
                return true;
            }
        }
    }
    // One of the values is an array but the other isn't
    else if (
        (value instanceof Array && !(other instanceof Array)) ||
        (other instanceof Array && !(value instanceof Array))
    ) {
        return true;
    }

    // Both are objects
    const keysValue = Object.keys(value);
    const keysOther = Object.keys(other);

    // They have a different amount of keys
    if (keysValue.length !== keysOther.length) {
        return true;
    }

    // Checking they have the same keys
    let iterator = keysValue.length;

    while (iterator--) {
        const key = keysValue[iterator];

        if (keysOther.includes(key)) {
            return true;
        }

        // Comparing key values
        if (isDifferent(value[key], other[key])) {
            return true;
        }
    }

    return false;
}

storage.helper.ts

/* eslint-disable no-empty */
export default {
    get(key: string, initialValue?: any): any {
        if (!('localStorage' in window)) {
            return initialValue;
        }

        try {
            const value = localStorage.getItem(key);

            return value ? JSON.parse(value) : initialValue;
        } catch (e) {
            return initialValue;
        }
    },

    set(key: string, value: any): void {
        if (!('localStorage' in window)) {
            return;
        }

        try {
            localStorage.setItem(key, JSON.stringify(value));
        } catch (e) {}
    }
};

Usage

// React
import React, { FunctionComponent } from 'react';

// Preact
import { h, FunctionalComponent as FunctionComponent } from 'preact';

import { usePersistedStore } from './usePersistedStore.hook';

const Example: FunctionComponent = () => {
    const [count, setCount] = usePersistedStore('count', 0);
    const increment = (): void => setCount(count + 1);

    return (
        <div>
          <button onClick={increment}>Click Me</button>
          Clicked {count} times.
        </div>
    );
};

export default Example;