Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useLocalStorage hook #29

Merged
merged 17 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/utils-reference/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
68 changes: 68 additions & 0 deletions docs/utils-reference/react-hooks/useLocalStorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# `useLocalStorage`

A hook to manage a value in the local storage.

## Signature

```ts
function useLocalStorage<T>(key: string, initialValue?: T): {
value: T | undefined;
setValue: (value: T) => Promise<void>;
removeValue: () => Promise<void>;
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had another thought: I'm wondering if we could support a setTodos(todos => ...) signature for the setter (similar to the setter of useState). This could some async check 🤔
But I guess it can come in a follow up PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd keep it simple for now. We can always iterate on it later since it won't be a breaking change.

}

return (
<List isLoading={isLoading}>
{todos?.map((todo) => (
<List.Item
icon={todo.done ? { source: Icon.Checkmark, tintColor: Color.Green } : Icon.Circle}
key={todo.id}
title={todo.title}
actions={
<ActionPanel>
<Action title={todo.done ? "Uncomplete" : "Complete"} onAction={() => toggleTodo(todo.id)} />
<Action title="Delete" style={Action.Style.Destructive} onAction={() => toggleTodo(todo.id)} />
</ActionPanel>
}
/>
))}
</List>
);
}
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from "./useSQL";
export * from "./useForm";
export * from "./useAI";
export * from "./useFrecencySorting";
export * from "./useLocalStorage";

export * from "./icon";

Expand Down
23 changes: 1 addition & 22 deletions src/useCachedState.ts
Original file line number Diff line number Diff line change
@@ -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<string | symbol, Cache>();
Expand Down
64 changes: 64 additions & 0 deletions src/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -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<string>("my-key");
* const { value, setValue } = useLocalStorage<string>("my-key", "default value");
* ```
*/
export function useLocalStorage<T>(key: string, initialValue?: T) {
const {
data: value,
isLoading,
mutate,
} = usePromise(
async (storageKey: string) => {
const item = await LocalStorage.getItem<string>(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 };
}
7 changes: 7 additions & 0 deletions tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
35 changes: 35 additions & 0 deletions tests/src/local-storage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<List isLoading={isLoading}>
{todos?.map((todo) => (
<List.Item
icon={todo.done ? { source: Icon.Checkmark, tintColor: Color.Green } : Icon.Circle}
key={todo.id}
title={todo.title}
actions={
<ActionPanel>
<Action title={todo.done ? "Uncomplete" : "Complete"} onAction={() => toggleTodo(todo.id)} />
<Action title="Delete" style={Action.Style.Destructive} onAction={() => toggleTodo(todo.id)} />
</ActionPanel>
}
/>
))}
</List>
);
}
Loading