mysticsymbolic.github.io/lib/page-with-shareable-state.tsx

114 wiersze
3.5 KiB
TypeScript

import React, { useCallback, useContext, useEffect, useState } from "react";
import { PageContext, PAGE_QUERY_ARG } from "./page";
export type ComponentWithShareableStateProps<T> = {
/** 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<T> = {
/** 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<ComponentWithShareableStateProps<T>>;
};
/** 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<T>({
defaultValue,
deserialize,
serialize,
component,
}: PageWithShareableStateOptions<T>): 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 <Component key={key} defaults={defaults} onChange={onChange} />;
};
return PageWithShareableState;
}