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 2 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 @@ -22,3 +22,4 @@
- [useSQL](utils-reference/react-hooks/useSQL.md)
- [useAI](utils-reference/react-hooks/useAI.md)
- [useFrecencySorting](utils-reference/react-hooks/useFrecencySorting.md)
- [useLocalStorage](utils-reference/react-hooks/useLocalStorage.md)
90 changes: 90 additions & 0 deletions docs/utils-reference/react-hooks/useLocalStorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# `useLocalStorage`

A hook to manage a value in the local storage.

The hook uses `useCachedPromise` internally to cache the value and provide a loading state. The value is stored as a JSON string in the local storage.

## Signature

```ts
function useLocalStorage<T>(key: string, initialValue: T): UseLocalStorageReturnValue<T>;
function useLocalStorage<T>(key: string): UseLocalStorageReturnValueWithUndefined<T>;
```

### 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 } = 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>
{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" onAction={() => toggleTodo(todo.id)} />
</ActionPanel>
}
/>
))}
</List>
);
}
```

## Types

### UseLocalStorageReturnValue

```ts
type UseLocalStorageReturnValue<T> = {
value: T;
setValue: (value: T) => Promise<void>;
removeValue: () => Promise<void>;
isLoading: boolean;
};
```

### UseLocalStorageReturnValueWithUndefined

```ts
type UseLocalStorageReturnValueWithUndefined<T> = {
value?: T;
setValue: (value: T) => Promise<void>;
removeValue: () => Promise<void>;
isLoading: boolean;
};
```
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.13.5",
"version": "1.14.0",
"description": "Set of utilities to streamline building Raycast extensions",
"author": "Raycast Technologies Ltd.",
"homepage": "https://developers.raycast.com/utils-reference",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./useSQL";
export * from "./useForm";
export * from "./useAI";
export * from "./useFrecencySorting";
export * from "./useLocalStorage";

export * from "./icon";

Expand Down
81 changes: 81 additions & 0 deletions src/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { LocalStorage } from "@raycast/api";
import { useCachedPromise } from "./useCachedPromise";
import { showFailureToast } from "./showFailureToast";

export type UseLocalStorageReturnValue<T> = {
value: T;
setValue: (value: T) => Promise<void>;
removeValue: () => Promise<void>;
isLoading: boolean;
};

export type UseLocalStorageReturnValueWithUndefined<T> = {
value?: T;
setValue: (value: T) => Promise<void>;
removeValue: () => Promise<void>;
isLoading: boolean;
};

/**
* A hook to manage a value in the local storage.
*
* @remark The hook uses `useCachedPromise` internally to cache the value and provide a loading state.
thomaslombart marked this conversation as resolved.
Show resolved Hide resolved
* @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): UseLocalStorageReturnValue<T>;
export function useLocalStorage<T>(key: string): UseLocalStorageReturnValueWithUndefined<T>;
export function useLocalStorage<T>(key: string, initialValue?: T) {
const {
data: value,
isLoading,
mutate,
} = useCachedPromise(
async (storageKey: string) => {
const item = await LocalStorage.getItem<string>(storageKey);

if (item) {
return JSON.parse(item);
thomaslombart marked this conversation as resolved.
Show resolved Hide resolved
}
},
[key],
{
onError(error) {
showFailureToast(error, { title: "Failed to get value from local storage" });
thomaslombart marked this conversation as resolved.
Show resolved Hide resolved
},
},
);

async function setValue(value: T) {
try {
await LocalStorage.setItem(key, JSON.stringify(value));
await mutate();
thomaslombart marked this conversation as resolved.
Show resolved Hide resolved
} catch (error) {
await showFailureToast(error, { title: "Failed to set value in local storage" });
}
}

async function removeValue() {
try {
await LocalStorage.removeItem(key);
await mutate();
} catch (error) {
await showFailureToast(error, { title: "Failed to remove value from local storage" });
}
}

return { value: value ?? initialValue, setValue, removeValue, isLoading };
Copy link
Member

Choose a reason for hiding this comment

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

Any reason to use the initialValue here instead of passing it to the useCachedState?

Thinking about it more, I think that when the cache is wiped (which can happen at any time) the initialValue will flicker even if there's something on the localStorage.

I'm wondering if we actually want the useCachedPromise and the initialValue 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did you mean useCachedPromise instead of useCachedState? The reason is to avoid having undefined returned even if the user passed an initial value. What would you suggest instead?

Copy link
Member

Choose a reason for hiding this comment

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

yes sorry.

The reason is to avoid having undefined returned even if the user passed an initial value.

I don't understand, useCachedPromise should do that already.

But yeah I don't think caching the value and returning the initial value if there is no cache is what we want. I understand that it would allow you to get the previous value (maybe, sometimes) without having to wait but it will lead to flickers

Copy link
Contributor Author

@thomaslombart thomaslombart Apr 17, 2024

Choose a reason for hiding this comment

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

I don't understand, useCachedPromise should do that already.

I don't know if that's intended or not, but even if I passed the initialData option to useCachedPromise, the value can still be undefined for a brief moment. I'd expect it to never be undefined if there's an initial value 🤔

CleanShot 2024-04-17 at 11 33 29@2x

So should we go with usePromise then?

Copy link
Member

Choose a reason for hiding this comment

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

I don't know if that's intended or not, but even if I passed the initialData option to useCachedPromise, the value can still be undefined for a brief moment

Hum that's a bug then. Do you have a repro?

So should we go with usePromise then?

I'd say so yeah

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But, won't we get flickers as well with usePromise? If an initial value is passed, the data will always load and we can get flickers as well if the value in the local storage is different than the initial one. Here's an example where the initial value is a list of three to-dos:

CleanShot.2024-04-18.at.12.08.27.mp4

Unless we should get rid of initialValue as well?

Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if we actually want the useCachedPromise and the initialValue

That's what I said yeah, I don't think we need either

}
7 changes: 7 additions & 0 deletions tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@
"required": true
}
]
},
{
"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 } = 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>
{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" onAction={() => toggleTodo(todo.id)} />
</ActionPanel>
}
/>
))}
</List>
);
}
Loading