Skip to content

Can't add new keys to persisted stores - feature request for different defaults handing #748

Open

Description

@tslocke

Describe the bug

  const [,setStore] = makePersisted(createStore({a: 1}), {name: 'x'})
  setStore({a: 2}) // trigger storage (because initial values are not persisted)
  const [store2, setStore2] = makePersisted(createStore({a: 1, b: 1}), {name: 'x'})
  console.log(Object.keys(store2)) // ['a']

I would expect ['a', 'b']

While I'm here: these stores are so useful and convenient! Thanks for this.

Minimal Reproduction Link

https://stackblitz.com/edit/github-cickt7r1?file=src%2Findex.tsx

Activity

  1. atk commented on Feb 8, 2025

    @atk
    Member

    The initial value of store2 is being immediately overwritten by the value from localStorage that is saved by the first persisted store with the name x. You can add new keys all right - you just cannot rely on them being there when initialized from a storage that does contain a version without them.

  2. tslocke commented on Feb 8, 2025

    @tslocke
    Author

    This seems to make it difficult to develop the component later. If I add new store properties I can't rely on then being initialised. Why not merge the stored object with the initial one, if both are present?

  3. atk commented on Feb 8, 2025

    @atk
    Member

    That's an interesting and valid argument and I will think about how to handle the issue. In the meantime, there's a workaround:

    Additional defaults would need to be added after persistence,. Alternatively, you could also split your state.

  4. reopened this on Feb 8, 2025
  5. self-assigned this
    on Feb 8, 2025
  6. changed the title [-]Can't add new keys to persisted stores[/-] [+]Can't add new keys to persisted stores - feature request for different defaults handing[/+] on Feb 8, 2025
  7. tslocke commented on Feb 23, 2025

    @tslocke
    Author

    Did you come to any decision about this @atk ?

  8. atk commented on Feb 23, 2025

    @atk
    Member

    Since the initialization is handled by createSignal/Store, there is nothing I can do about it. However, I can add a defaults value to the options that performs the same workaround in a more convenient manner; the rest would be a question of documentation.

  9. tslocke commented on Feb 28, 2025

    @tslocke
    Author

    The change I was proposing would be inside makePersisted. The function has

      const init = storage.getItem(name, storageOptions);

    to load the persisted data, which is then written to the store using your local set function, overwriting any default values.

    I'm suggesting you instead do something like:

      const loaded = storage.getItem(name, storageOptions);
      const init = typeof signal[0] == 'function' ? {...signal[0](), ...loaded} : {...signal[0], ...loaded}

    Another option would be not to use reconcile inside set, which means you would pick up the normal object merging behaviour of stores. Do I understand correctly that your persisted stores lose this behaviour?

    Maybe the simple fix is just to make the reconcile behaviour optional?

  10. tslocke commented on Feb 28, 2025

    @tslocke
    Author

    Oops posted without testing - that's all wrong! 🙈 Working on it...

  11. tslocke commented on Feb 28, 2025

    @tslocke
    Author

    OK the change is a bit more convoluted than I thought because the merge needs to happen after deserialisation, so inside set, and set is also used to support the sync option. Not a big deal but not a one-liner.

    For now I'm using a forked version that doesn't reconcile. What do you think about making that optional? What am I missing out on without that?

    I don't want to work around this in my application code because that would mean unchanged initial values would be persisted which I would rather avoid.

  12. Azq2 commented on Mar 7, 2025

    @Azq2

    This package is missing two important features:

    1. Merging with initial state
    2. Migration

    makePersisted.ts - enhanced makePersisted implementation

    import { SetStoreFunction, Store, reconcile, unwrap } from 'solid-js/store';
    
    export interface PersistentOptions<T> {
      name: string;
      storage?: Storage;
      version?: number;
      serialize?: (data: unknown) => string | undefined | null;
      deserialize?: (data: unknown) => string | undefined | null;
      migrate?: (persistedState: T, version: number) => T;
      merge?: (persistedState: T, currentState: T) => T;
      skipLoad?: boolean;
    }
    
    interface PersistentInternal {
      version: number;
      state: unknown;
    }
    
    export type PersistentStore<T> = [get: Store<T>, set: SetStoreFunction<T>];
    
    export function makePersisted<T>(store: [Store<T>, SetStoreFunction<T>], options: PersistentOptions<T>): PersistentStore<T> {
      const {
        name,
        storage = localStorage,
        version = 0,
        migrate = (persistedState) => persistedState,
        merge = (persistedState, currentState) => ({ ...currentState, ...persistedState }),
        serialize = JSON.stringify,
        deserialize = JSON.parse,
        skipLoad,
      } = options;
    
      const storedValue = skipLoad ? null : storage.getItem(name);
      if (storedValue !== null) {
        const parsed = deserialize(storedValue) as PersistentInternal;
        if (parsed != null && parsed.version !== null) {
          const typedState = parsed.state as T;
          if (parsed.version !== version) {
            parsed.state = migrate(typedState, parsed.version);
            parsed.version = version;
          }
          parsed.state = merge(typedState, unwrap(store[0]));
          store[1](reconcile(typedState));
        }
      }
    
      const updateStorage = () => {
        const serialized = serialize({
          version,
          state: unwrap(store[0]) as unknown,
        } as PersistentInternal);
        if (serialized != null) {
          storage.setItem(name, serialized);
        } else {
          storage.removeItem(name);
        }
      };
    
      const setWithPersistence: SetStoreFunction<T> = (...args: any) => {
        (store[1] as any)(...args);
        updateStorage();
      };
    
      return [store[0], setWithPersistence];
    }

    createStoreWithApi.ts - zustand-like persistent store

    import { PersistentOptions, makePersisted } from './makePersisted';
    import { createRoot } from 'solid-js';
    import { SetStoreFunction, createStore } from 'solid-js/store';
    
    export interface CreateStoreWithApiOptions<S> {
      persist?: PersistentOptions<S>;
    }
    
    export function createStoreWithApi<S extends object, A>(
      initialData: () => S,
      createApi: (store: S, set: SetStoreFunction<S>) => A,
      options?: CreateStoreWithApiOptions<S>
    ): () => [store: S, api: A] {
      const [store, setStore] = createRoot(() => {
        return options?.persist
          ? makePersisted(createStore<S>(initialData()), options.persist)
          : createStore<S>(initialData());
      });
      const api = createApi(store, setStore);
      return () => [store, api];
    }

    Example - persistent store

    const [state, setState] = makePersisted(createStore({ value: 123 }), { name: 'some-storage-key' });
    state.value // read
    setState("value", 321) // write

    Example - persistent with api

    import { createStoreWithApi } from "@/utils/createStoreWithApi";
    
    interface SwilibSummaryState {
      showOldNames: boolean;
      groupByFile: boolean;
      globalCollapsed: boolean;
      coverageType: string;
      groupIsCollapsed: Record<string, boolean>;
      groupPrimarySort: Record<string, 'ASC' | 'DESC'>;
    }
    
    export const useSwilibSummaryStore = createStoreWithApi(() => ({
      showOldNames: true,
      groupByFile: false,
      globalCollapsed: false,
      coverageType: 'SWI',
      groupIsCollapsed: {},
      groupPrimarySort: {},
    } as SwilibSummaryState), (_, set) => ({
      setShowOldNames: (value: boolean) => set("showOldNames", value),
      setGroupByFile: (value: boolean) => set("groupByFile", value),
      setGlobalCollapsed: (value: boolean) => set("globalCollapsed", value),
      setCoverageType: (value: string) => set("coverageType", value),
      setGroupPrimarySort: (group: string, value: 'ASC' | 'DESC') => set("groupPrimarySort", group, value),
      setGroupCollapsed: (group: string, value: boolean) => set("groupIsCollapsed", group, value),
    }), { persist: { name: 'swilib-table-state' } });
    export const SwilibTable: Component<SwilibTableProps> = (props) => {
      const [state, api] = useSwilibSummaryStore();
      const isCollapsed = () => state.groupIsCollapsed[props.file] ?? false;
      const primarySortOrder = () => state.groupPrimarySort[props.file] ?? 'ASC';
    ...
    }
    <TableSortButton
      value={primarySortOrder()}
      onChange={(sort) => api.setGroupPrimarySort(props.file, sort)}
    >
      <small>ID</small>
    </TableSortButton>
  13. added
    bugSomething isn't working
    help wantedExtra attention is needed
    and removed
    bugSomething isn't working
    on Jul 4, 2026
  14. added theissue type on Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

help wantedExtra attention is needed

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions