import React, { useCallback, useContext, useEffect, useState } from "react"; import { PageContext, PAGE_QUERY_ARG } from "./page"; export type ComponentWithShareableStateProps = { /** The default state to use when the component is first rendered. */ defaults: T; /** * Callback to trigger whenever the shareable state changes. Note * that each call will add a new entry to the browser's navigation history. * As such, every call to this function will mark a boundary where the * user can press the "back" button to go back to the previous state. */ onChange: (value: T) => void; }; export type PageWithShareableStateOptions = { /** The default shareable state. */ defaultValue: T; /** * Deserialize the given state, throwing an exception * if it's invalid in any way. */ deserialize: (value: string) => T; /** Serialize the given state to a string. */ serialize: (value: T) => string; /** Component to render the page. */ component: React.ComponentType>; }; /** The query string argument that will store the serialized state. */ export const STATE_QUERY_ARG = "s"; /** * Create a component that represents a page which exposes some * aspect of its state in the current URL, so that it can be * easily shared, recorded in the browser history, etc. */ export function createPageWithShareableState({ defaultValue, deserialize, serialize, component, }: PageWithShareableStateOptions): React.FC<{}> { const Component = component; const PageWithShareableState: React.FC<{}> = () => { const { search, pushState, currPage } = useContext(PageContext); /** The current serialized state, as reflected in the URL bar. */ const state = search.get(STATE_QUERY_ARG) || serialize(defaultValue); /** * What we think the latest serialized state is; used to determine whether * the user navigated in their browser history. */ const [latestState, setLatestState] = useState(state); /** * The key to use when rendering our page component. This will * be incremented whenever the user navigates their browser * history, to ensure that our component resets itself to the * default state we pass in. */ const [key, setKey] = useState(0); /** * Remembers whether we're in the middle of an update triggered by * our own component. */ const [isInOnChange, setIsInOnChange] = useState(false); /** The default state from th URL, which we'll pass into our component. */ let defaults: T = defaultValue; try { defaults = deserialize(state || ""); } catch (e) { console.log(`Error deserializing state: ${e}`); } const onChange = useCallback( (value: T) => { const newState = serialize(value); if (state !== newState) { const newSearch = new URLSearchParams(); newSearch.set(PAGE_QUERY_ARG, currPage); newSearch.set(STATE_QUERY_ARG, newState); setIsInOnChange(true); setLatestState(newState); pushState("?" + newSearch.toString()); setIsInOnChange(false); } }, [state, currPage, pushState] ); useEffect(() => { if (!isInOnChange && latestState !== state) { // The user navigated in their browser. setLatestState(state); setKey(key + 1); } }, [isInOnChange, state, latestState, key]); return ; }; return PageWithShareableState; }