Skip to content

Commit

Permalink
feat: atom-like storage state (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
SukkaW authored Oct 26, 2024
1 parent dd5fe6c commit 3044472
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 5 deletions.
2 changes: 2 additions & 0 deletions docs/src/pages/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
"title": "Utilities"
},
"context-state": {},
"context-local-storage-state": {},
"context-session-storage-state": {},
"create-fixed-array": {},
"invariant-nullthrow": {},
"noop": {},
Expand Down
69 changes: 69 additions & 0 deletions docs/src/pages/create-local-storage-state.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: Create localStorage State
---

# Create localStorage State

import ExportMetaInfo from '../components/export-meta-info';

<ExportMetaInfo />

Store your shared state that lives in localStorage and synchronize the state of a component with the data stored in the `localStorage`. Lift your state up and passing them deeply into your React app without worrying about performance.

## Usage

First, create a shared hooks with `createLocalStorageState`. It is recommended to place them in a separate file:

```tsx filename="src/context/sidebar-active.tsx" copy
import { createLocalStorageState } from 'foxact/create-local-storage-state';

const [useSidebarActive, useSidebarActiveValue] = createLocalStorageState(
'sidebar-active', // The localStorage key
/**
* The initial value to use if there is no item in the local storage with the provided key,
* the undefined value will be used if no initial value is provided.
*
* Also, the initial value will also be used during the server-side rendering, see below.
*/
false,
/**
* Optional configuration object enables the customization of value serialization before it's stored in local storage.
*/
{
// Optional, default to false. When set to "true", the value will be passed to the localStorage API as is.
raw: false,
// Optional, default to "JSON.stringify". Can only be specified when the "raw" is set to false (which is the default).
serializer: JSON.stringify,
// Optional, default to "JSON.parse". Can only be specified when the "raw" is set to false (which is the default).
deserializer: JSON.parse,
}
);

export { useSidebarActive, useSidebarActiveValue };
```

And now you can use the getter and setter hooks anywhere in your app:

```tsx filename="src/components/sidebar.tsx" copy
import { memo } from 'react';
import { useSidebarActive, useSidebarActiveValue } from '../context/sidebar-active';

function Sidebar() {
const [sidebarActive, setSidebarActive] = useSidebarActive();
// If you only need the value, you can use `useSidebarActiveValue` instead:
const sidebarActive = useSidebarActiveValue();

return (
<div className={`sidebar ${sidebarActive ? 'active' : ''}`}>
<button onClick={() => setSidebarActive(false)}>Close Sidebar</button>
</div>
);
}

export default memo(Sidebar);
```

## Sever-side Rendering

If the second argument (the initial value) is provided, React will use the initial value to render the UI and generate HTML on the server, otherwise React will find the closest `<Suspense>` boundary and render its `fallback` UI into the generated server HTML on the server. See
[`useLocalStorage`](/use-local-storage#sever-side-rendering) for more information.
66 changes: 66 additions & 0 deletions docs/src/pages/create-session-storage-state.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: Create sessionStorage State
---

# Create sessionStorage State

import ExportMetaInfo from '../components/export-meta-info';

<ExportMetaInfo />

Store your shared state that lives in sessionStorage and synchronize the state of a component with the data stored in the `localStorage`. Lift your state up and passing them deeply into your React app without worrying about performance.

## Usage

```tsx filename="src/context/sidebar-active.tsx" copy
import { createSessionStorageState } from 'foxact/create-session-storage-state';

const [useSidebarActive, useSidebarActiveValue] = createSessionStorageState(
'sidebar-active', // The localStorage key
/**
* The initial value to use if there is no item in the local storage with the provided key,
* the undefined value will be used if no initial value is provided.
*
* Also, the initial value will also be used during the server-side rendering, see below.
*/
false,
/**
* Optional configuration object enables the customization of value serialization before it's stored in local storage.
*/
{
// Optional, default to false. When set to "true", the value will be passed to the localStorage API as is.
raw: false,
// Optional, default to "JSON.stringify". Can only be specified when the "raw" is set to false (which is the default).
serializer: JSON.stringify,
// Optional, default to "JSON.parse". Can only be specified when the "raw" is set to false (which is the default).
deserializer: JSON.parse,
}
);

export { useSidebarActive, useSidebarActiveValue };
```

And now you can use the getter and setter hooks anywhere in your app:

```tsx filename="src/components/sidebar.tsx" copy
import { memo } from 'react';
import { useSidebarActive, useSidebarActiveValue } from '../context/sidebar-active';

function Sidebar() {
const [sidebarActive, setSidebarActive] = useSidebarActive();
// If you only need the value, you can use `useSidebarActiveValue` instead:
const sidebarActive = useSidebarActiveValue();

return (
<div className={`sidebar ${sidebarActive ? 'active' : ''}`}>
<button onClick={() => setSidebarActive(false)}>Close Sidebar</button>
</div>
);
}

export default memo(Sidebar);
```

## Sever-side Rendering

See [createLocalStorageState](/create-local-storage-state#sever-side-rendering) for more details.
20 changes: 20 additions & 0 deletions src/create-local-storage-state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'client-only';

import { createStorageStateFactory } from '../create-storage-state-factory';

/**
* @see https://foxact.skk.moe/create-local-storage-state
*
* @example
* ```ts
* const [useOpenState, useOpen] = createLocalStorageState(
* 'open', // storage key
* false, // server default value
* { raw: false } // options
* );
*
* const [open, setOpen] = useOpenState();
* const open = useOpen();
* ```
*/
export const createLocalStorageState = createStorageStateFactory('localStorage');
21 changes: 21 additions & 0 deletions src/create-session-storage-state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'client-only';

import { createStorageStateFactory } from '../create-storage-state-factory';

/**
* @see https://foxact.skk.moe/create-session-storage-state
*
* @example
* ```ts
* ```ts
* const [useOpenState, useOpen] = createSessionStorageState(
* 'open', // storage key
* false, // server default value
* { raw: false } // options
* );
*
* const [open, setOpen] = useOpenState();
* const open = useOpen();
* ```
*/
export const createSessionStorageState = createStorageStateFactory('sessionStorage');
12 changes: 7 additions & 5 deletions src/create-storage-hook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { noop } from '../noop';
import { useLayoutEffect } from '../use-isomorphic-layout-effect';
import { noSSRError } from '../no-ssr';

type StorageType = 'localStorage' | 'sessionStorage';
type NotUndefined<T> = T extends undefined ? never : T;
export type StorageType = 'localStorage' | 'sessionStorage';
export type NotUndefined<T> = T extends undefined ? never : T;

export type StateHookTuple<T> = readonly [T, React.Dispatch<React.SetStateAction<T | null>>];

// StorageEvent is deliberately not fired on the same document, we do not want to change that
type CustomStorageEvent = CustomEvent<string>;
Expand Down Expand Up @@ -105,13 +107,13 @@ export function createStorage(type: StorageType) {
key: string,
serverValue: NotUndefined<T>,
options?: UseStorageRawOption | UseStorageParserOption<T>
): readonly [T, React.Dispatch<React.SetStateAction<T | null>>];
): StateHookTuple<T>;
// client-render only
function useStorage<T>(
key: string,
serverValue?: undefined,
options?: UseStorageRawOption | UseStorageParserOption<T>
): readonly [T | null, React.Dispatch<React.SetStateAction<T | null>>];
): StateHookTuple<T | null>;
function useStorage<T>(
key: string,
serverValue?: NotUndefined<T>,
Expand All @@ -121,7 +123,7 @@ export function createStorage(type: StorageType) {
serializer: JSON.stringify,
deserializer: JSON.parse
}
): readonly [T | null, React.Dispatch<React.SetStateAction<T | null>>] | readonly [T, React.Dispatch<React.SetStateAction<T | null>>] {
): StateHookTuple<T> | StateHookTuple<T | null> {
const subscribeToSpecificKeyOfLocalStorage = useCallback((callback: () => void) => {
if (typeof window === 'undefined') {
return noop;
Expand Down
42 changes: 42 additions & 0 deletions src/create-storage-state-factory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createStorage } from '../create-storage-hook';
import type { NotUndefined, StateHookTuple, StorageType, UseStorageParserOption, UseStorageRawOption } from '../create-storage-hook';

const identity = (x: any) => x;

export type ValueHook<T> = () => T;
export type SetValueHook<T> = () => (value: T) => void;
export type StateHook<T> = () => StateHookTuple<T>;

export function createStorageStateFactory(type: StorageType) {
function createStorageState<T>(
key: string,
serverValue: NotUndefined<T>,
options?: UseStorageRawOption | UseStorageParserOption<T>
): readonly [StateHook<T>, ValueHook<T>, SetValueHook<T | null>];
function createStorageState<T>(
key: string,
serverValue?: undefined,
options?: UseStorageRawOption | UseStorageParserOption<T>
): readonly [StateHook<T | null>, ValueHook<T | null>, SetValueHook<T | null>];
function createStorageState<T>(
key: string,
serverValue?: NotUndefined<T>,
// eslint-disable-next-line sukka/unicorn/no-object-as-default-parameter -- two different shape of options
options: UseStorageRawOption | UseStorageParserOption<T> = {
raw: false,
serializer: JSON.stringify,
deserializer: JSON.parse
}
): readonly [StateHook<T>, ValueHook<T>, SetValueHook<T | null>] | readonly [StateHook<T | null>, ValueHook<T | null>, SetValueHook<T | null>] {
const { useStorage: useStorageOriginal, useSetStorage: useSetStorageOriginal } = createStorage(type);

const useStorage = () => useStorageOriginal<T>(key, serverValue as any, options);
const useStorageState = () => useStorageOriginal<T>(key, serverValue as any, options)[0];

const useSetStorageValue = () => useSetStorageOriginal(key, options.raw ? identity : options.serializer);

return [useStorage, useStorageState, useSetStorageValue];
};

return createStorageState;
}

0 comments on commit 3044472

Please sign in to comment.