leptos_use/storage/
use_storage.rs

1use crate::{core::MaybeRwSignal, storage::StorageType, utils::FilterOptions};
2use codee::{CodecError, Decoder, Encoder};
3use default_struct_builder::DefaultBuilder;
4use leptos::prelude::*;
5use leptos::reactive::wrappers::read::Signal;
6use std::sync::Arc;
7use thiserror::Error;
8use wasm_bindgen::JsValue;
9
10const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage";
11
12/// Reactive [Storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage).
13///
14/// The function returns a triplet `(read_signal, write_signal, delete_from_storage_fn)`.
15///
16/// ## Demo
17///
18/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_storage)
19///
20/// ## Usage
21///
22/// Pass a [`StorageType`] to determine the kind of key-value browser storage to use.
23/// The specified key is where data is stored. All values are stored as UTF-16 strings which
24/// is then encoded and decoded via the given `*Codec`. This value is synced with other calls using
25/// the same key on the same page and across tabs for local storage.
26/// See [`UseStorageOptions`] to see how behavior can be further customised.
27///
28/// Values are (en)decoded via the given codec. You can use any of the string codecs or a
29/// binary codec wrapped in `Base64`.
30///
31/// > Please check [the codec chapter](https://leptos-use.rs/codecs.html) to see what codecs are
32/// > available and what feature flags they require.
33///
34/// ## Example
35///
36/// ```
37/// # use leptos::prelude::*;
38/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage};
39/// # use serde::{Deserialize, Serialize};
40/// # use codee::string::{FromToStringCodec, JsonSerdeCodec, Base64};
41/// # use codee::binary::ProstCodec;
42/// #
43/// # #[component]
44/// # pub fn Demo() -> impl IntoView {
45/// // Binds a struct:
46/// let (state, set_state, _) = use_local_storage::<MyState, JsonSerdeCodec>("my-state");
47///
48/// // Binds a bool, stored as a string:
49/// let (flag, set_flag, remove_flag) = use_session_storage::<bool, FromToStringCodec>("my-flag");
50///
51/// // Binds a number, stored as a string:
52/// let (count, set_count, _) = use_session_storage::<i32, FromToStringCodec>("my-count");
53/// // Binds a number, stored in JSON:
54/// let (count, set_count, _) = use_session_storage::<i32, JsonSerdeCodec>("my-count-kept-in-js");
55///
56/// // Bind string with SessionStorage stored in ProtoBuf format:
57/// let (id, set_id, _) = use_storage::<String, Base64<ProstCodec>>(
58///     StorageType::Session,
59///     "my-id",
60/// );
61/// #    view! { }
62/// # }
63///
64/// // Data stored in JSON must implement Serialize, Deserialize.
65/// // And you have to add the feature "serde" to your project's Cargo.toml
66/// #[derive(Serialize, Deserialize, Clone, PartialEq)]
67/// pub struct MyState {
68///     pub hello: String,
69///     pub greeting: String,
70/// }
71///
72/// // Default can be used to implement initial or deleted values.
73/// // You can also use a signal via UseStorageOptions::default_value`
74/// impl Default for MyState {
75///     fn default() -> Self {
76///         Self {
77///             hello: "hi".to_string(),
78///             greeting: "Hello".to_string()
79///         }
80///     }
81/// }
82/// ```
83///
84/// ## Server-Side Rendering
85///
86/// On the server the returned signals will just read/manipulate the `initial_value` without persistence.
87///
88/// ### Hydration bugs and `use_cookie`
89///
90/// If you use a value from storage to control conditional rendering you might run into issues with
91/// hydration.
92///
93/// ```
94/// # use leptos::prelude::*;
95/// # use leptos_use::storage::use_session_storage;
96/// # use codee::string::FromToStringCodec;
97/// #
98/// # #[component]
99/// # pub fn Example() -> impl IntoView {
100/// let (flag, set_flag, _) = use_session_storage::<bool, FromToStringCodec>("my-flag");
101///
102/// view! {
103///     <Show when=move || flag.get()>
104///         <div>Some conditional content</div>
105///     </Show>
106/// }
107/// # }
108/// ```
109///
110/// You can see hydration warnings in the browser console and the conditional parts of
111/// the app might never show up when rendered on the server and then hydrated in the browser. The
112/// reason for this is that the server has no access to storage and therefore will always use
113/// `initial_value` as described above. So on the server your app is always rendered as if
114/// the value from storage was `initial_value`. Then in the browser the actual stored value is used
115/// which might be different, hence during hydration the DOM looks different from the one rendered
116/// on the server which produces the hydration warnings.
117///
118/// The recommended way to avoid this is to use `use_cookie` instead because values stored in cookies
119/// are available on the server as well as in the browser.
120///
121/// If you still want to use storage instead of cookies you can use the `delay_during_hydration`
122/// option that will use the `initial_value` during hydration just as on the server and delay loading
123/// the value from storage by an animation frame. This gets rid of the hydration warnings and makes
124/// the app correctly render things. Some flickering might be unavoidable though.
125///
126/// ```
127/// # use leptos::prelude::*;
128/// # use leptos_use::storage::{use_local_storage_with_options, UseStorageOptions};
129/// # use codee::string::FromToStringCodec;
130/// #
131/// # #[component]
132/// # pub fn Example() -> impl IntoView {
133/// let (flag, set_flag, _) = use_local_storage_with_options::<bool, FromToStringCodec>(
134///     "my-flag",
135///     UseStorageOptions::default().delay_during_hydration(true),
136/// );
137///
138/// view! {
139///     <Show when=move || flag.get()>
140///         <div>Some conditional content</div>
141///     </Show>
142/// }
143/// # }
144/// ```
145#[inline(always)]
146pub fn use_storage<T, C>(
147    storage_type: StorageType,
148    key: impl Into<Signal<String>>,
149) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone + Send + Sync)
150where
151    T: Default + Clone + PartialEq + Send + Sync + 'static,
152    C: Encoder<T, Encoded = String> + Decoder<T, Encoded = str>,
153{
154    use_storage_with_options::<T, C>(storage_type, key, UseStorageOptions::default())
155}
156
157/// Version of [`use_storage`] that accepts [`UseStorageOptions`].
158pub fn use_storage_with_options<T, C>(
159    storage_type: StorageType,
160    key: impl Into<Signal<String>>,
161    options: UseStorageOptions<T, <C as Encoder<T>>::Error, <C as Decoder<T>>::Error>,
162) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone + Send + Sync)
163where
164    T: Clone + PartialEq + Send + Sync,
165    C: Encoder<T, Encoded = String> + Decoder<T, Encoded = str>,
166{
167    let UseStorageOptions {
168        on_error,
169        listen_to_storage_changes,
170        initial_value,
171        filter,
172        delay_during_hydration,
173    } = options;
174
175    let (data, set_data) = initial_value.into_signal();
176    let default = data.get_untracked();
177
178    #[cfg(feature = "ssr")]
179    {
180        let _ = on_error;
181        let _ = listen_to_storage_changes;
182        let _ = filter;
183        let _ = delay_during_hydration;
184        let _ = storage_type;
185        let _ = key;
186        let _ = INTERNAL_STORAGE_EVENT;
187
188        let remove = move || {
189            set_data.set(default.clone());
190        };
191
192        (data, set_data, remove)
193    }
194
195    #[cfg(not(feature = "ssr"))]
196    {
197        use crate::{
198            sendwrap_fn, use_event_listener, use_window, watch_with_options, WatchOptions,
199        };
200        use send_wrapper::SendWrapper;
201
202        let delaying = StoredValue::new(
203            delay_during_hydration
204                && Owner::current_shared_context()
205                    .map(|sc| sc.during_hydration())
206                    .unwrap_or_default(),
207        );
208
209        let key = key.into();
210
211        // Get storage API
212        let storage = storage_type
213            .into_storage()
214            .map_err(UseStorageError::StorageNotAvailable)
215            .and_then(|s| s.ok_or(UseStorageError::StorageReturnedNone));
216        let storage = handle_error(&on_error, storage);
217
218        // Schedules a storage event microtask. Uses a queue to avoid re-entering the runtime
219        let dispatch_storage_event = {
220            let on_error = on_error.to_owned();
221
222            move || {
223                let on_error = on_error.to_owned();
224
225                queue_microtask(move || {
226                    // TODO : better to use a BroadcastChannel (use_broadcast_channel)?
227                    // Note: we cannot construct a full StorageEvent so we _must_ rely on a custom event
228                    let custom = web_sys::CustomEventInit::new();
229                    custom.set_detail(&JsValue::from_str(&key.get_untracked()));
230                    let result = window()
231                        .dispatch_event(
232                            &web_sys::CustomEvent::new_with_event_init_dict(
233                                INTERNAL_STORAGE_EVENT,
234                                &custom,
235                            )
236                            .expect("failed to create custom storage event"),
237                        )
238                        .map_err(UseStorageError::NotifyItemChangedFailed);
239                    let _ = handle_error(&on_error, result);
240                })
241            }
242        };
243
244        let read_from_storage = {
245            let storage = storage.to_owned();
246            let on_error = on_error.to_owned();
247
248            move || {
249                storage
250                    .to_owned()
251                    .and_then(|storage| {
252                        // Get directly from storage
253                        let result = storage
254                            .get_item(&key.get_untracked())
255                            .map_err(UseStorageError::GetItemFailed);
256                        handle_error(&on_error, result)
257                    })
258                    .unwrap_or_default() // Drop handled Err(())
259                    .as_ref()
260                    .map(|encoded| {
261                        // Decode item
262                        let result = C::decode(encoded)
263                            .map_err(|e| UseStorageError::ItemCodecError(CodecError::Decode(e)));
264                        handle_error(&on_error, result)
265                    })
266                    .transpose()
267                    .unwrap_or_default() // Drop handled Err(())
268            }
269        };
270
271        // Fetches direct from browser storage and fills set_data if changed (memo)
272        let fetch_from_storage = {
273            let default = default.clone();
274            let read_from_storage = read_from_storage.clone();
275
276            SendWrapper::new(move || {
277                let fetched = read_from_storage();
278
279                match fetched {
280                    Some(value) => {
281                        // Replace data if changed
282                        if value != data.get_untracked() {
283                            set_data.set(value)
284                        }
285                    }
286
287                    // Revert to default
288                    None => set_data.set(default.clone()),
289                };
290            })
291        };
292
293        // Fires when storage needs to be fetched
294        let notify = ArcTrigger::new();
295
296        // Refetch from storage. Keeps track of how many times we've been notified. Does not increment for calls to set_data
297        let notify_id = Memo::<usize>::new({
298            let notify = notify.clone();
299            let fetch_from_storage = fetch_from_storage.clone();
300
301            move |prev| {
302                notify.track();
303                match prev {
304                    None => 1, // Avoid async fetch of initial value
305                    Some(prev) => {
306                        fetch_from_storage();
307                        prev + 1
308                    }
309                }
310            }
311        });
312
313        // Set item on internal (non-event) page changes to the data signal
314        {
315            let storage = storage.to_owned();
316            let on_error = on_error.to_owned();
317            let dispatch_storage_event = dispatch_storage_event.to_owned();
318
319            let _ = watch_with_options(
320                move || (notify_id.get(), data.get()),
321                move |(id, value), prev, _| {
322                    // Skip setting storage on changes from external events. The ID will change on external events.
323                    let change_from_external_event =
324                        prev.map(|(prev_id, _)| *prev_id != *id).unwrap_or_default();
325
326                    if change_from_external_event {
327                        return;
328                    }
329
330                    // Don't write default value if store is empty or still hydrating
331                    if value == &default && (read_from_storage().is_none() || delaying.get_value())
332                    {
333                        return;
334                    }
335
336                    if let Ok(storage) = &storage {
337                        // Encode value
338                        let result = C::encode(value)
339                            .map_err(|e| UseStorageError::ItemCodecError(CodecError::Encode(e)))
340                            .and_then(|enc_value| {
341                                // Set storage -- sends a global event
342                                storage
343                                    .set_item(&key.get_untracked(), &enc_value)
344                                    .map_err(UseStorageError::SetItemFailed)
345                            });
346                        let result = handle_error(&on_error, result);
347                        // Send internal storage event
348                        if result.is_ok() {
349                            dispatch_storage_event();
350                        }
351                    }
352                },
353                WatchOptions::default().filter(filter).immediate(true),
354            );
355        }
356
357        if delaying.get_value() {
358            request_animation_frame({
359                let fetch_from_storage = fetch_from_storage.clone();
360                move || {
361                    delaying.set_value(false);
362                    fetch_from_storage()
363                }
364            });
365        } else {
366            fetch_from_storage();
367        }
368
369        if listen_to_storage_changes {
370            // Listen to global storage events
371            let _ = use_event_listener(use_window(), leptos::ev::storage, {
372                let notify = notify.clone();
373
374                move |ev| {
375                    let ev_key = ev.key();
376                    // Key matches or all keys deleted (None)
377                    if ev_key == Some(key.get_untracked()) || ev_key.is_none() {
378                        notify.notify()
379                    }
380                }
381            });
382            // Listen to internal storage events
383            let _ = use_event_listener(
384                use_window(),
385                leptos::ev::Custom::new(INTERNAL_STORAGE_EVENT),
386                {
387                    let notify = notify.clone();
388
389                    move |ev: web_sys::CustomEvent| {
390                        if Some(key.get_untracked()) == ev.detail().as_string() {
391                            notify.notify()
392                        }
393                    }
394                },
395            );
396        };
397
398        Effect::watch(
399            move || key.get(),
400            {
401                let notify = notify.clone();
402                move |_, _, _| notify.notify()
403            },
404            false,
405        );
406
407        // Remove from storage fn
408        let remove = {
409            sendwrap_fn!(move || {
410                let _ = storage.as_ref().map(|storage| {
411                    // Delete directly from storage
412                    let result = storage
413                        .remove_item(&key.get_untracked())
414                        .map_err(UseStorageError::RemoveItemFailed);
415                    let _ = handle_error(&on_error, result);
416                    notify.notify();
417                    dispatch_storage_event();
418                });
419            })
420        };
421
422        (data, set_data, remove)
423    }
424}
425
426/// Session handling errors returned by [`use_storage_with_options`].
427#[derive(Error, Debug)]
428pub enum UseStorageError<E, D> {
429    #[error("storage not available")]
430    StorageNotAvailable(JsValue),
431    #[error("storage not returned from window")]
432    StorageReturnedNone,
433    #[error("failed to get item")]
434    GetItemFailed(JsValue),
435    #[error("failed to set item")]
436    SetItemFailed(JsValue),
437    #[error("failed to delete item")]
438    RemoveItemFailed(JsValue),
439    #[error("failed to notify item changed")]
440    NotifyItemChangedFailed(JsValue),
441    #[error("failed to encode / decode item value")]
442    ItemCodecError(CodecError<E, D>),
443}
444
445/// Options for use with [`fn@crate::storage::use_local_storage_with_options`], [`fn@crate::storage::use_session_storage_with_options`] and [`use_storage_with_options`].
446#[derive(DefaultBuilder)]
447pub struct UseStorageOptions<T, E, D>
448where
449    T: Send + Sync + 'static,
450{
451    // Callback for when an error occurs
452    #[builder(skip)]
453    on_error: Arc<dyn Fn(UseStorageError<E, D>) + Send + Sync>,
454    // Whether to continuously listen to changes from browser storage
455    listen_to_storage_changes: bool,
456    // Initial value to use when the storage key is not set
457    #[builder(skip)]
458    initial_value: MaybeRwSignal<T>,
459    // Debounce or throttle the writing to storage whenever the value changes
460    #[builder(into)]
461    filter: FilterOptions,
462    /// Delays the reading of the value from storage by one animation frame during hydration.
463    /// This ensures that during hydration the value is the initial value just like it is on the server
464    /// which helps prevent hydration errors. Defaults to `false`.
465    delay_during_hydration: bool,
466}
467
468/// Calls the on_error callback with the given error. Removes the error from the Result to avoid double error handling.
469#[cfg(not(feature = "ssr"))]
470fn handle_error<T, E, D>(
471    on_error: &Arc<dyn Fn(UseStorageError<E, D>) + Send + Sync>,
472    result: Result<T, UseStorageError<E, D>>,
473) -> Result<T, ()> {
474    result.map_err(|err| (on_error)(err))
475}
476
477impl<T: Default, E, D> Default for UseStorageOptions<T, E, D>
478where
479    T: Send + Sync + 'static,
480{
481    fn default() -> Self {
482        Self {
483            on_error: Arc::new(|_err| ()),
484            listen_to_storage_changes: true,
485            initial_value: MaybeRwSignal::default(),
486            filter: FilterOptions::default(),
487            delay_during_hydration: false,
488        }
489    }
490}
491
492impl<T: Default, E, D> UseStorageOptions<T, E, D>
493where
494    T: Send + Sync + 'static,
495{
496    /// Optional callback whenever an error occurs.
497    pub fn on_error(
498        self,
499        on_error: impl Fn(UseStorageError<E, D>) + Send + Sync + 'static,
500    ) -> Self {
501        Self {
502            on_error: Arc::new(on_error),
503            ..self
504        }
505    }
506
507    /// Initial value to use when the storage key is not set. Note that this value is read once on creation of the storage hook and not updated again. Accepts a signal and defaults to `T::default()`.
508    pub fn initial_value(self, initial: impl Into<MaybeRwSignal<T>>) -> Self {
509        Self {
510            initial_value: initial.into(),
511            ..self
512        }
513    }
514}