diff --git a/docs/utils-reference/SUMMARY.md b/docs/utils-reference/SUMMARY.md index 115730d..3a6a237 100644 --- a/docs/utils-reference/SUMMARY.md +++ b/docs/utils-reference/SUMMARY.md @@ -23,3 +23,4 @@ - [useAI](utils-reference/react-hooks/useAI.md) - [useFrecencySorting](utils-reference/react-hooks/useFrecencySorting.md) - [useStreamJSON](utils-reference/react-hooks/useStreamJSON.md) + - [useLocalStorage](utils-reference/react-hooks/useLocalStorage.md) diff --git a/docs/utils-reference/react-hooks/useLocalStorage.md b/docs/utils-reference/react-hooks/useLocalStorage.md new file mode 100644 index 0000000..624c2d8 --- /dev/null +++ b/docs/utils-reference/react-hooks/useLocalStorage.md @@ -0,0 +1,68 @@ +# `useLocalStorage` + +A hook to manage a value in the local storage. + +## Signature + +```ts +function useLocalStorage(key: string, initialValue?: T): { + value: T | undefined; + setValue: (value: T) => Promise; + removeValue: () => Promise; + isLoading: boolean; +} +``` + +### Arguments + +- `key` - The key to use for the value in the local storage. +- `initialValue` - The initial value to use if the key doesn't exist in the local storage. + +### Return + +Returns an object with the following properties: + +- `value` - The value from the local storage or the initial value if the key doesn't exist. +- `setValue` - A function to update the value in the local storage. +- `removeValue` - A function to remove the value from the local storage. +- `isLoading` - A boolean indicating if the value is loading. + +## Example + +```tsx +import { Action, ActionPanel, Color, Icon, List } from "@raycast/api"; +import { useLocalStorage } from "@raycast/utils"; + +const exampleTodos = [ + { id: "1", title: "Buy milk", done: false }, + { id: "2", title: "Walk the dog", done: false }, + { id: "3", title: "Call mom", done: false }, +]; + +export default function Command() { + const { value: todos, setValue: setTodos, isLoading } = useLocalStorage("todos", exampleTodos); + + async function toggleTodo(id: string) { + const newTodos = todos?.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)) ?? []; + await setTodos(newTodos); + } + + return ( + + {todos?.map((todo) => ( + + toggleTodo(todo.id)} /> + toggleTodo(todo.id)} /> + + } + /> + ))} + + ); +} +``` diff --git a/package.json b/package.json index ca2a55f..f018caf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raycast/utils", - "version": "1.14.1", + "version": "1.15.0", "description": "Set of utilities to streamline building Raycast extensions", "author": "Raycast Technologies Ltd.", "homepage": "https://developers.raycast.com/utils-reference", diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..de024ec --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,21 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function replacer(this: any, key: string, _value: unknown) { + const value = this[key]; + if (value instanceof Date) { + return `__raycast_cached_date__${value.toString()}`; + } + if (Buffer.isBuffer(value)) { + return `__raycast_cached_buffer__${value.toString("base64")}`; + } + return _value; +} + +export function reviver(_key: string, value: unknown) { + if (typeof value === "string" && value.startsWith("__raycast_cached_date__")) { + return new Date(value.replace("__raycast_cached_date__", "")); + } + if (typeof value === "string" && value.startsWith("__raycast_cached_buffer__")) { + return Buffer.from(value.replace("__raycast_cached_buffer__", ""), "base64"); + } + return value; +} diff --git a/src/index.ts b/src/index.ts index a031a03..22b004c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from "./useSQL"; export * from "./useForm"; export * from "./useAI"; export * from "./useFrecencySorting"; +export * from "./useLocalStorage"; export * from "./icon"; diff --git a/src/useCachedState.ts b/src/useCachedState.ts index ac5fae1..5486974 100644 --- a/src/useCachedState.ts +++ b/src/useCachedState.ts @@ -1,28 +1,7 @@ import { useCallback, Dispatch, SetStateAction, useSyncExternalStore, useMemo } from "react"; import { Cache } from "@raycast/api"; import { useLatest } from "./useLatest"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function replacer(this: any, key: string, _value: unknown) { - const value = this[key]; - if (value instanceof Date) { - return `__raycast_cached_date__${value.toString()}`; - } - if (Buffer.isBuffer(value)) { - return `__raycast_cached_buffer__${value.toString("base64")}`; - } - return _value; -} - -function reviver(_key: string, value: unknown) { - if (typeof value === "string" && value.startsWith("__raycast_cached_date__")) { - return new Date(value.replace("__raycast_cached_date__", "")); - } - if (typeof value === "string" && value.startsWith("__raycast_cached_buffer__")) { - return Buffer.from(value.replace("__raycast_cached_buffer__", ""), "base64"); - } - return value; -} +import { replacer, reviver } from "./helpers"; const rootCache = Symbol("cache without namespace"); const cacheMap = new Map(); diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts new file mode 100644 index 0000000..3e7f27a --- /dev/null +++ b/src/useLocalStorage.ts @@ -0,0 +1,64 @@ +import { LocalStorage } from "@raycast/api"; +import { showFailureToast } from "./showFailureToast"; +import { replacer, reviver } from "./helpers"; +import { usePromise } from "./usePromise"; + +/** + * A hook to manage a value in the local storage. + * + * @remark The value is stored as a JSON string in the local storage. + * + * @param key - The key to use for the value in the local storage. + * @param initialValue - The initial value to use if the key doesn't exist in the local storage. + * @returns An object with the following properties: + * - `value`: The value from the local storage or the initial value if the key doesn't exist. + * - `setValue`: A function to update the value in the local storage. + * - `removeValue`: A function to remove the value from the local storage. + * - `isLoading`: A boolean indicating if the value is loading. + * + * @example + * ``` + * const { value, setValue } = useLocalStorage("my-key"); + * const { value, setValue } = useLocalStorage("my-key", "default value"); + * ``` + */ +export function useLocalStorage(key: string, initialValue?: T) { + const { + data: value, + isLoading, + mutate, + } = usePromise( + async (storageKey: string) => { + const item = await LocalStorage.getItem(storageKey); + + return typeof item !== "undefined" ? (JSON.parse(item, reviver) as T) : initialValue; + }, + [key], + ); + + async function setValue(value: T) { + try { + await mutate(LocalStorage.setItem(key, JSON.stringify(value, replacer)), { + optimisticUpdate(value) { + return value; + }, + }); + } catch (error) { + await showFailureToast(error, { title: "Failed to set value in local storage" }); + } + } + + async function removeValue() { + try { + await mutate(LocalStorage.removeItem(key), { + optimisticUpdate() { + return undefined; + }, + }); + } catch (error) { + await showFailureToast(error, { title: "Failed to remove value from local storage" }); + } + } + + return { value, setValue, removeValue, isLoading }; +} diff --git a/tests/package.json b/tests/package.json index 7accb7b..51cea28 100644 --- a/tests/package.json +++ b/tests/package.json @@ -185,6 +185,13 @@ "subtitle": "Utils Smoke Tests", "description": "Utils Smoke Tests", "mode": "view" + }, + { + "name": "local-storage", + "title": "useLocalStorage", + "subtitle": "Utils Smoke Tests", + "description": "Utils Smoke Tests", + "mode": "view" } ], "dependencies": { diff --git a/tests/src/local-storage.tsx b/tests/src/local-storage.tsx new file mode 100644 index 0000000..924e310 --- /dev/null +++ b/tests/src/local-storage.tsx @@ -0,0 +1,35 @@ +import { Action, ActionPanel, Color, Icon, List } from "@raycast/api"; +import { useLocalStorage } from "@raycast/utils"; + +const exampleTodos = [ + { id: "1", title: "Buy milk", done: false }, + { id: "2", title: "Walk the dog", done: false }, + { id: "3", title: "Call mom", done: false }, +]; + +export default function Command() { + const { value: todos, setValue: setTodos, isLoading } = useLocalStorage("todos", exampleTodos); + + async function toggleTodo(id: string) { + const newTodos = todos?.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)) ?? []; + await setTodos(newTodos); + } + + return ( + + {todos?.map((todo) => ( + + toggleTodo(todo.id)} /> + toggleTodo(todo.id)} /> + + } + /> + ))} + + ); +}