diff --git a/package.json b/package.json index 9105465..1ea089f 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "sass-loader": "^11.0.1", "semantic-release": "^19.0.3", "ts-jest": "^26.5.5", - "tslib": "^2.2.0", + "tslib": "^2.4.0", "typescript": "^4.2.4" }, "config": { diff --git a/src/hooks/useLocalStorage/index.ts b/src/hooks/useLocalStorage/index.ts new file mode 100644 index 0000000..01cda12 --- /dev/null +++ b/src/hooks/useLocalStorage/index.ts @@ -0,0 +1 @@ +export * from './useLocalStorage'; diff --git a/src/hooks/useLocalStorage/useLocalStorage.stories.mdx b/src/hooks/useLocalStorage/useLocalStorage.stories.mdx new file mode 100644 index 0000000..e2a0a09 --- /dev/null +++ b/src/hooks/useLocalStorage/useLocalStorage.stories.mdx @@ -0,0 +1,117 @@ +import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks'; +import { useLocalStorage } from './useLocalStorage'; +import { + ClearAllExamplesButton, + PersistentCounterExample, + PersistentTextFieldExample, + PersistentCheckboxExample, + WelcomeModalExample, + ReusedKeyExample, + CustomHookExample, + ComplexValueExample, +} from './useLocalStorage.stories.tsx'; +import GithubLink from '../../../.storybook/helpers/GithubLink'; + + + +# useLocalStorage + +A custom hook resembling `React.useState` which persists and synchronizes any JSON-serializable value using the +browser's [localStorage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). + +The value will persist across page reloads, and updates to the value will cause a re-render anywhere `useLocalStorage` +is called with the same unique `key` string. The value identified by a `key` will stay in sync between multiple calls +within the same page, across multiple pages and across multiple tabs/windows via a `StorageEvent` listener. The value +will only be lost if the user switches to a different browser or a fresh incognito session, or if they clear their +browsing data. + +Just like `useState`, `useLocalStorage` returns the current value and setter function in an array tuple so they can be +given arbitrary names using [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), +and it supports TypeScript [generics](https://www.typescriptlang.org/docs/handbook/generics.html) and can optionally +infer its return types based on the `defaultValue`. + +```ts +const [value, setValue] = useLocalStorage(key: string, defaultValue: T); +``` + +## Notes + +On first render, `value` will either be synchronously loaded from storage or will fall back to `defaultValue` if there +is no value in storage. If necessary, the data can be fully removed from `localStorage` by calling +`setValue(undefined)`, which will re-render with `value` set back to `defaultValue`. Note that this is distinct from +calling `setValue(null)`, which will persist and synchronize the `null` value. + +If `setValue` is called with a value which cannot be serialized to JSON +([see Exceptions here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#exceptions)), +or if an error is thrown by the `localStorage` API, the error is caught and logged to the console and the `value` falls +back to the `defaultValue`. + +`useLocalStorage` is safe to use in a server-side rendering or unit testing environment where `window.localStorage` is +not available. If `window` is undefined, the hook will simply always use the `defaultValue` and the setter function +will only update the cached value in React state. + +## Examples + +For each of these examples, try opening this page in multiple windows side-by-side to see the synchronization in action, +and try reloading the page to see the persistence in action. + +### Persistent counter (`number` value) + +The classic `useState` example, but persistent. The `count` variable's type here is inferred as a primitive `number`, +not a `string`. + + + + + +### Persistent text field (`string` value) + + + + + +### Persistent checkbox (`boolean` value) + + + + + +### Modal with "Don't show this again" checkbox + +This button will simulate navigating to a page that has a welcome modal which can be disabled for future visits by the user. + + + + + +### Sharing a persistent value by reusing the same `key` + +The same value can be rendered and updated in multiple different components and in the same component rendered in multiple places +by reusing the same unique `key` string. In this implementation, you will need to use the same `defaultValue` for each instance +or you'll end up with the values out of sync until one of them is changed by the user. See the next example for an improvement. + + + + + +### Sharing a persistent value by factoring out into a custom hook + +Sharing the same value can be done more easily (and without the repeated `defaultValue` problem) by creating a custom hook +wrapping your `useLocalStorage` call and using the custom hook in multiple places. + + + + + +### Persistent array and object values + +Any JSON-serializable value can be used with `useLocalStorage` such as an array or an object. Note that this may not be +desirable; if possible it is simpler to use multiple instances of `useLocalStorage` with separate keys and individual primitive values. + + + + + + + + diff --git a/src/hooks/useLocalStorage/useLocalStorage.stories.tsx b/src/hooks/useLocalStorage/useLocalStorage.stories.tsx new file mode 100644 index 0000000..04ff2ae --- /dev/null +++ b/src/hooks/useLocalStorage/useLocalStorage.stories.tsx @@ -0,0 +1,271 @@ +import * as React from 'react'; +import * as yup from 'yup'; +import { + Button, + Checkbox, + TextContent, + Text, + TextInput, + Modal, + NumberInput, + List, + ListItem, + Form, + FormGroup, +} from '@patternfly/react-core'; +import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; +import { useLocalStorage } from './useLocalStorage'; +import { useFormState, useFormField } from '../useFormState'; +import { ValidatedTextInput } from '../../components/ValidatedTextInput'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export const PersistentCounterExample: React.FunctionComponent = () => { + const [count, setCount] = useLocalStorage('exampleCounter', 0); + return ( + setCount(count - 1)} + onPlus={() => setCount(count + 1)} + onChange={(event) => setCount(Number(event.currentTarget.value))} + inputName="input" + inputAriaLabel="number input" + minusBtnAriaLabel="minus" + plusBtnAriaLabel="plus" + /> + ); +}; + +export const PersistentTextFieldExample: React.FunctionComponent = () => { + const [value, setValue] = useLocalStorage('exampleTextField', ''); + return ( + + ); +}; + +export const PersistentCheckboxExample: React.FunctionComponent = () => { + const [isChecked, setIsChecked] = useLocalStorage('exampleCheckboxChecked', false); + return ( + + ); +}; + +export const WelcomeModalExample: React.FunctionComponent = () => { + const ExamplePage: React.FunctionComponent = () => { + const [isModalDisabled, setIsModalDisabled] = useLocalStorage('welcomeModalDisabled', false); + const [isModalOpen, setIsModalOpen] = React.useState(!isModalDisabled); + return ( + <> + + Example Page Title + You reached the example page! + + If you checked the "don't show this again" box, try reloading the page + and returning here and you'll see the welcome modal won't come back. If you + want to see it again, clear your browsing data or try an incognito tab, or use the + "Clear localStorage for all examples" button above. + + + + setIsModalOpen(false)} + actions={[ + , + ]} + > + + + This is an introductory message that you will see each time you visit the example + page, unless you check the box below! Tell your users what this page is all about, but + let them choose not to be annoyed with it every time. + + + + + + ); + }; + + // The following is just for the embedded example to work. In a real app you'd just follow the above ExamplePage. + const [isExamplePageOpen, setIsExamplePageOpen] = React.useState(false); + if (!isExamplePageOpen) { + return ( + + ); + } + return ; +}; + +export const ReusedKeyExample: React.FunctionComponent = () => { + // In a real app each of these components would be in separate files. + const ComponentA: React.FunctionComponent = () => { + const [value, setValue] = useLocalStorage('exampleReusedKey', 'default value here'); + return ( +
+ + Component A + + +
+ ); + }; + const ComponentB: React.FunctionComponent = () => { + const [value] = useLocalStorage('exampleReusedKey', 'default value here'); + return ( +
+ + Component B + {value} + +
+ ); + }; + return ( + <> + + + + + ); +}; + +export const CustomHookExample: React.FunctionComponent = () => { + // This could be exported from its own file and imported in multiple component files. + const useMyStoredValue = () => useLocalStorage('myStoredValue', 'default defined once'); + + // In a real app each of these components would be in separate files. + const ComponentA: React.FunctionComponent = () => { + const [value, setValue] = useMyStoredValue(); + return ( +
+ + Component A + + +
+ ); + }; + const ComponentB: React.FunctionComponent = () => { + const [value] = useLocalStorage('exampleReusedKey', 'default value here'); + return ( +
+ + Component B + {value} + +
+ ); + }; + return ( + <> + + + + + ); +}; + +export const ComplexValueExample: React.FunctionComponent = () => { + type Item = { name: string; description?: string }; + const [items, setItems] = useLocalStorage('exampleArray', []); + + const addForm = useFormState({ + name: useFormField('', yup.string().required().label('Name')), + description: useFormField('', yup.string().label('Description')), + }); + + return ( + <> + + Saved items + {items.length > 0 ? ( + + {items.map((item, index) => ( + + Name: {item.name} + {item.description ? <>, Description: {item.description} : null} + + + ))} + + ) : ( + No items yet + )} + + + Add item: + +
+ + + + + + + + ); +}; + +export const ClearAllExamplesButton: React.FunctionComponent = () => ( +
+ +
+); diff --git a/src/hooks/useLocalStorage/useLocalStorage.ts b/src/hooks/useLocalStorage/useLocalStorage.ts new file mode 100644 index 0000000..9ba0a39 --- /dev/null +++ b/src/hooks/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,67 @@ +import * as React from 'react'; + +const getValueFromStorage = (key: string, defaultValue: T): T => { + if (typeof window === 'undefined') return defaultValue; + try { + const itemJSON = window.localStorage.getItem(key); + return itemJSON ? (JSON.parse(itemJSON) as T) : defaultValue; + } catch (error) { + console.error(error); + return defaultValue; + } +}; + +const setValueInStorage = (key: string, newValue: T | undefined) => { + if (typeof window === 'undefined') return; + try { + if (newValue !== undefined) { + const newValueJSON = JSON.stringify(newValue); + window.localStorage.setItem(key, newValueJSON); + // setItem only causes the StorageEvent to be dispatched in other windows. We dispatch it here + // manually so that all instances of useLocalStorage on this window also react to this change. + window.dispatchEvent(new StorageEvent('storage', { key, newValue: newValueJSON })); + } else { + window.localStorage.removeItem(key); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: null })); + } + } catch (error) { + console.error(error); + } +}; + +export const useLocalStorage = ( + key: string, + defaultValue: T +): [T, React.Dispatch>] => { + const [cachedValue, setCachedValue] = React.useState(getValueFromStorage(key, defaultValue)); + + const setValue: React.Dispatch> = React.useCallback( + (newValueOrFn: T | ((prevState: T) => T)) => { + const newValue = + newValueOrFn instanceof Function + ? newValueOrFn(getValueFromStorage(key, defaultValue)) + : newValueOrFn; + setValueInStorage(key, newValue); + if (typeof window === 'undefined') { + // If we're in a server or test environment, the cache won't update automatically since there's no StorageEvent. + setCachedValue(newValue); + } + }, + [key, defaultValue] + ); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + const onStorageUpdated = (event: StorageEvent) => { + if (event.key === key) { + setCachedValue(event.newValue ? JSON.parse(event.newValue) : defaultValue); + } + }; + window.addEventListener('storage', onStorageUpdated); + return () => { + window.removeEventListener('storage', onStorageUpdated); + }; + }, [key, defaultValue]); + + return [cachedValue, setValue]; +}; diff --git a/src/index.ts b/src/index.ts index 00b4d7d..0c9766b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,6 @@ export * from './components/LoadingEmptyState'; export * from './hooks/useSelectionState'; export * from './hooks/useFormState'; +export * from './hooks/useLocalStorage'; export * from './modules/kube-client'; diff --git a/yarn.lock b/yarn.lock index 00b2b1c..de2e21c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14913,7 +14913,7 @@ tslib@^2.0.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== -tslib@^2.0.1, tslib@^2.2.0: +tslib@^2.0.1: version "2.2.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==