Created
August 6, 2019 15:06
-
-
Save vincentriemer/1dae699bc126b0d2f20dc435324e0d63 to your computer and use it in GitHub Desktop.
My take on a type-safe usePersistedState hook, leveraging React suspense/concurrent-mode, DOM custom events, and the kv-storage builtin module.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @flow | |
*/ | |
import * as React from "react"; | |
import { unstable_createResource } from "react-cache"; | |
import * as Scheduler from "scheduler"; | |
import { StorageArea } from "std:kv-storage"; | |
// Creates a ref that tracks the latest value of the argument passed to it | |
import { useLatestValueRef } from "~/Hooks/useLatestValueRef"; | |
const STORAGE_EVENT_NAME = (persistKey: string) => | |
`peristed-state-storage:${persistKey}`; | |
const storage = new StorageArea("react-persisted-state"); | |
const storageResource: {| read: string => mixed |} = unstable_createResource( | |
async (key: string): Promise<mixed> => { | |
return await storage.get(key); | |
} | |
); | |
const persistedStateMemoryStore: { [key: string]: mixed } = {}; | |
function dispatchStorageEvent(name: string, state: mixed): void { | |
const storageEvent = new CustomEvent(name, { detail: state }); | |
document.dispatchEvent(storageEvent); | |
} | |
// type constraint that enforces json-compatible values | |
type JSONValue = | |
| string | |
| number | |
| boolean | |
| null | |
| { [string]: JSONValue } | |
| Array<JSONValue>; | |
// state updater function that has the same shape as React.useState's | |
type StateUpdater<T: JSONValue> = (nextState: (T => T) | T) => void; | |
// validator that takes in an unknown value from the store and returns | |
// the expected type if it validates and undefined otherwise | |
type StateValidator<T: JSONValue> = mixed => T | void; | |
function usePersistedState<T: JSONValue>( | |
defaultValue: T | (() => T), | |
validatePersistedValue: StateValidator<T>, | |
persistKey: string | |
): [T, StateUpdater<T>] { | |
const memoryInitialState = persistedStateMemoryStore[persistKey]; | |
const storageInitialState = storageResource.read(persistKey); | |
const [initialStateFactory] = React.useState( | |
(): T | (() => T) => { | |
// get the inital state in priority order: Memory -> Storage -> Default | |
const validatedMemory = validatePersistedValue(memoryInitialState); | |
if (validatedMemory !== undefined) { | |
return validatedMemory; | |
} | |
const validatedStorage = validatePersistedValue(storageInitialState); | |
if (validatedStorage !== undefined) { | |
return validatedStorage; | |
} | |
return defaultValue; | |
} | |
); | |
const [state, updateState] = React.useState(initialStateFactory); | |
const stateRef = useLatestValueRef(state); | |
const persistKeyRef = useLatestValueRef(persistKey); | |
const stateValidatorRef = useLatestValueRef(validatePersistedValue); | |
const stateUpdater: StateUpdater<T> = React.useCallback( | |
nextState => { | |
// resolve the next state | |
let newState = null; | |
if (typeof nextState === "function") { | |
const currentState = stateRef.current; | |
newState = nextState(currentState); | |
} else { | |
newState = nextState; | |
} | |
// update current instance's state | |
updateState(newState); | |
// update memory store | |
persistedStateMemoryStore[persistKeyRef.current] = newState; | |
// send event out to update other instances | |
dispatchStorageEvent(STORAGE_EVENT_NAME(persistKeyRef.current), newState); | |
// update the persisted store at a lower priority | |
Scheduler.unstable_scheduleCallback( | |
Scheduler.unstable_LowPriority, | |
() => { | |
storage.set(persistKeyRef.current, newState); | |
} | |
); | |
}, | |
[persistKeyRef, stateRef] | |
); | |
// $FlowFixMe - Custom Events not typed in flow lib core | |
const handleStateChangeFromOtherInstance: EventListener = React.useCallback( | |
({ detail }: { detail: mixed }) => { | |
const newState = stateValidatorRef.current(detail); | |
// don't update the state if the incoming value is undefined or the current value. | |
// this is important because the instance will recieve state update events from itself | |
// and we want to avoid a double state update | |
if (newState !== undefined && newState !== stateRef.current) { | |
updateState(newState); | |
} | |
}, | |
[stateRef, stateValidatorRef] | |
); | |
// listen for updates from other instances | |
React.useEffect(() => { | |
const eventName = STORAGE_EVENT_NAME(persistKeyRef.current); | |
document.addEventListener( | |
eventName, | |
handleStateChangeFromOtherInstance, | |
false | |
); | |
return () => { | |
document.removeEventListener( | |
eventName, | |
handleStateChangeFromOtherInstance, | |
false | |
); | |
}; | |
}, [ | |
handleStateChangeFromOtherInstance, | |
persistKeyRef, | |
stateRef, | |
stateValidatorRef | |
]); | |
return [state, stateUpdater]; | |
} | |
export { usePersistedState }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment