-
Notifications
You must be signed in to change notification settings - Fork 10
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
Changes from 12 commits
bb44f46
ffe132e
114acf2
763ad01
d102ce0
f393dca
5454ee1
9356bff
65a1c24
fb0fde5
84c5b24
6d3f23f
f1cc83b
2588f56
0c4c161
46a7713
3baef0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
# `useLocalStorage` | ||
|
||
A hook to manage a value 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); | ||
} | ||
|
||
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; | ||
}; | ||
``` |
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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import { LocalStorage } from "@raycast/api"; | ||
import { useCachedPromise } from "./useCachedPromise"; | ||
import { showFailureToast } from "./showFailureToast"; | ||
import { replacer, reviver } from "./helpers"; | ||
|
||
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 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, reviver); | ||
} | ||
}, | ||
[key], | ||
); | ||
|
||
async function setValue(value: T) { | ||
try { | ||
await mutate(LocalStorage.setItem(key, JSON.stringify(value, replacer)), { | ||
optimisticUpdate() { | ||
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: value ?? initialValue, setValue, removeValue, isLoading }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason to use the 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you mean There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes sorry.
I don't understand, 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 So should we go with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hum that's a bug then. Do you have a repro?
I'd say so yeah There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But, won't we get flickers as well with CleanShot.2024-04-18.at.12.08.27.mp4Unless we should get rid of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's what I said yeah, I don't think we need either |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import { Action, ActionPanel, Color, Icon, List } from "@raycast/api"; | ||
import { useLocalStorage } from "@raycast/utils"; | ||
|
||
type Todo = { | ||
id: string; | ||
title: string; | ||
done: boolean; | ||
doneAt: Date | null; | ||
}; | ||
|
||
const exampleTodos: Todo[] = [ | ||
{ id: "1", title: "Buy milk", done: false, doneAt: null }, | ||
{ id: "2", title: "Walk the dog", done: false, doneAt: null }, | ||
{ id: "3", title: "Call mom", done: false, doneAt: null }, | ||
]; | ||
|
||
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, doneAt: new Date() } : todo)); | ||
await setTodos(newTodos); | ||
} | ||
|
||
return ( | ||
<List> | ||
{todos.map((todo) => { | ||
return ( | ||
<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> | ||
} | ||
accessories={[{ date: todo.doneAt ? todo.doneAt : undefined }]} | ||
/> | ||
); | ||
})} | ||
</List> | ||
); | ||
} |
There was a problem hiding this comment.
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 ofuseState
). This could some async check 🤔But I guess it can come in a follow up PR
There was a problem hiding this comment.
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.