Storage hook `undefined` during initial render

Hey! I'm using the useStorage hook and noticed that during the initial render, it is undefined (default value) and then gets populated with the value from storage. I understand why this is the case (async data store vs sync rendering), however is there any way we can make it so that all storages are loaded before the rest of the application shows (i.e. no initial render with default values)?
35 Replies
lab
lab•2y ago
You can do value ? <Loading> : <ActualStuff>
nahtnam
nahtnamOP•2y ago
And chain them, fair enough. Lemme give that a shot! This is a bit trickier than I thought. useStorage('favoriteNumber', (value) => (value === undefined ? 10 : value));. Initially this will render (and persist) the value 10. If the user then modifies the value in the settings to 20 and opens up the extension again, they will see a flash of 10 before 20. And we can't really do a null check because theres a default value I suppose I could try useStorage('favoriteNumber') and useEffect where if(!favoriteNumber) setStorage(10) but I'm not sure if that would cause issues
lab
lab•2y ago
interesting maybe we can add something to the 3rd return value of the hook so the first 2 values model after useState the 3rd value provides more than that maybe we can add a isHydrated bool in the 3rd one and then only if the value is hydrated would we set that to true what do you think? bc useStorage tries to be an API-replica of useState, the concept of rehydration doesn't quite sync up (since useState hydration is supposedly synced with the runtime and renderer).
nahtnam
nahtnamOP•2y ago
Sorry just got back, that’s exactly the kind of solution I was thinking! I was toying around with suspense but couldn’t figure it out But that would also be an alternative cool solution Ah I’m too slow, I see you already implemented it Thank you! I’ll let you know if I find any problems
lab
lab•2y ago
oh yeah I actually just released it lol try out the latest version and lmk how it goes xD
nahtnam
nahtnamOP•2y ago
Is there a way to return this promise as well? https://github.com/PlasmoHQ/storage/blob/main/src/hook.ts#L61
GitHub
storage/hook.ts at main · PlasmoHQ/storage
💾 Safely store data and share it across your extension - storage/hook.ts at main · PlasmoHQ/storage
nahtnam
nahtnamOP•2y ago
I ask because I was messing around to see if I can implement suspense so I dont have to check for isHydrated in every location I use the store https://www.youtube.com/watch?v=PtoQpVOQ5Ew Seems like you need to throw a promise and react re-renders when the promise resolves
lab
lab•2y ago
PR welcome :p
nahtnam
nahtnamOP•2y ago
Let me try 🙂
lab
lab•2y ago
in term of is there a way, I wonder we can store that promise in a ref and expose it in the 3rd arg?
nahtnam
nahtnamOP•2y ago
I wonder if its as simple as passing suspense = true as a prop to the hook and then if (suspense) throw on that line Ah its in the useEffect, nvm
lab
lab•2y ago
I ask because I was messing around to see if I can implement suspense so I dont have to check for isHydrated in every location I use the store
One way to solve this is to use a context/provider
nahtnam
nahtnamOP•2y ago
Can I clone only the storage and link it or do I have to do the whole suite? Yeah but suspense is cooler 😛
lab
lab•2y ago
I wonder if it works with parcel tho since suspense is dynamic import and the current bundler atm is actually pretty shoddy at dynamic resolving atm, more so for extension since they usually expects a single bundle code (this is something we will need to improve along the line and think about - is there a way to do React server component/suspense in extension? <- this is a new frontier for next year imo) Even in NextJS, they had to make their own dynamic function to handle dynamic import since the default async import just sucks and doesn't work in their runtime/isomorphic so very likely Plasmo will have to adopt a similar strategy if it wanted to support suspense/dynamic import
nahtnam
nahtnamOP•2y ago
Well I already did my own little suspense experiment inside of plasmo without an issue, but i also dont understand a lot of this stuff. I think youre right that a context is probably an easier solution
lab
lab•2y ago
ooh noice I had no idea suspense works with Plasmo lol (and was expecting it to not work at all xD) but actually interesting to hear it's working I suspect, Parcel is faking suspense - when you do suspense, do you see 2 or more script tags in your popup.html when looking at it from the inspector? or is there just a single script Re: cloning - I think you might need to clone the whole suite, since we link some of the global dependencies xD and the storage repo has no lockfile yeah I just checked the package.json it's using the workspace protocol, so you will have to work with it from the monorepo
nahtnam
nahtnamOP•2y ago
Ahhh kk gotcha, will give it a shot when I get some spare time! Hey, update here. I found a solution that works with suspense, caching and all that jazz.
const { queryKey, schema, defaultValue, queryOptions } = props;
const storageKey = queryKey.join('.');
const query = useQuery({
queryKey,
queryFn: async () => {
const store = await chromeLocalStorage.get<T>(storageKey);
const result = schema.safeParse(store);
if (!result.success) {
return defaultValue;
}
return result.data;
},
suspense: true,
meta: {
storable: false,
},
...queryOptions,
});

useEffect(() => {
chromeLocalStorage.watch({
[storageKey]: (change: { newValue: T }) => {
if (!_.isEqual(change.newValue, query.data)) {
query.refetch();
}
},
});
}, [query, storageKey]);

const mutation = useMutation({
mutationFn: async (setter: Setter<T>) => {
const newValue = setter instanceof Function ? setter(query.data) : setter;

const result = schema.safeParse(newValue);
if (!result.success) {
throw new Error(
`Invalid data passed to setter function. Failed validation ${result.error.message}`,
);
}
await chromeLocalStorage.set(storageKey, result.data);
return result.data;
},
onSuccess() {
query.refetch();
},
});

return {
query,
mutation,
};
const { queryKey, schema, defaultValue, queryOptions } = props;
const storageKey = queryKey.join('.');
const query = useQuery({
queryKey,
queryFn: async () => {
const store = await chromeLocalStorage.get<T>(storageKey);
const result = schema.safeParse(store);
if (!result.success) {
return defaultValue;
}
return result.data;
},
suspense: true,
meta: {
storable: false,
},
...queryOptions,
});

useEffect(() => {
chromeLocalStorage.watch({
[storageKey]: (change: { newValue: T }) => {
if (!_.isEqual(change.newValue, query.data)) {
query.refetch();
}
},
});
}, [query, storageKey]);

const mutation = useMutation({
mutationFn: async (setter: Setter<T>) => {
const newValue = setter instanceof Function ? setter(query.data) : setter;

const result = schema.safeParse(newValue);
if (!result.success) {
throw new Error(
`Invalid data passed to setter function. Failed validation ${result.error.message}`,
);
}
await chromeLocalStorage.set(storageKey, result.data);
return result.data;
},
onSuccess() {
query.refetch();
},
});

return {
query,
mutation,
};
I essentially used react-query to handle all of it
lab
lab•2y ago
Fascinating, how deep is your query btw (est. expected depth) I've been playing around with storage to implement custom history caching, so either hashing with namespace, or using query key like in your solution are some potential solution I've been looking at
nahtnam
nahtnamOP•2y ago
By deep do you mean in my react tree?
lab
lab•2y ago
yeah or like the longest query you're expecting
nahtnam
nahtnamOP•2y ago
I go pretty deep, I wanted to organize my code so that any component can access the stores without handling loading states. I also needed multiple instances of useBeautifulDashboardStorage to be cached so that I dont have to worry about each component that uses the hook to trigger the suspense loading state Here is some more code. Inside of any component I do this:
const { id } = props;
const { beautifulDashboardMutation, beautifulDashboardQuery } =
useBeautifulDashboardStorage({ id });

// No if statements or anything required for loading/errors, the wrapping suspense/error boundary will take care of all of that
const { id } = props;
const { beautifulDashboardMutation, beautifulDashboardQuery } =
useBeautifulDashboardStorage({ id });

// No if statements or anything required for loading/errors, the wrapping suspense/error boundary will take care of all of that
export function useBeautifulDashboardStorage({ id }: { id: string }) {
const KEY = ['storage', 'dashboards', 'beautiful', id];
const { query, mutation } = useBuildHooks({
queryKey: KEY,
defaultValue,
schema,
});

if (!query.data) throw new Error(`Suspense not called for ${KEY.join('.')}}`);

return {
beautifulDashboardQuery: query,
beautifulDashboardMutation: mutation,
};
}
export function useBeautifulDashboardStorage({ id }: { id: string }) {
const KEY = ['storage', 'dashboards', 'beautiful', id];
const { query, mutation } = useBuildHooks({
queryKey: KEY,
defaultValue,
schema,
});

if (!query.data) throw new Error(`Suspense not called for ${KEY.join('.')}}`);

return {
beautifulDashboardQuery: query,
beautifulDashboardMutation: mutation,
};
}
useBuildHooks is the code in my message above Last thing you need to do is to tell react-query to cache the queries to localStorage
const persistOptions: Omit<PersistQueryClientOptions, 'queryClient'> = {
persister: asyncStoragePersister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const isValid = query.state.status === 'success';
const isStorable = (query.meta?.storable as boolean) ?? true;
return isValid && isStorable;
},
},
};
const persistOptions: Omit<PersistQueryClientOptions, 'queryClient'> = {
persister: asyncStoragePersister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const isValid = query.state.status === 'success';
const isStorable = (query.meta?.storable as boolean) ?? true;
return isValid && isStorable;
},
},
};
nahtnam
nahtnamOP•2y ago
At the end of the day I think Im going to go with RxDB for my storage. Unfortunately this means that I'd have to use IndexedDB instead of the chrome storage since it looks like writing an RXDB adapter would be a pain: https://github.com/pubkey/rxdb/blob/master/src/plugins/storage-dexie/rx-storage-dexie.ts
GitHub
rxdb/rx-storage-dexie.ts at master · pubkey/rxdb
A fast, offline-first, reactive database for JavaScript Applications - rxdb/rx-storage-dexie.ts at master · pubkey/rxdb
nahtnam
nahtnamOP•2y ago
I think the reason why Id want to go with RXDB is because it provides: - Migrations, ensuring the future builds wont break the data structure I have stored - Validation of the stored data, so having a number instead of string doesnt throw an accidentally reset my whole application In terms of syncing, they do have some paid options but I dont forsee allowing syncing anyways I'd probably stick with my react-query hook above to actually load the data (and trigger a suspense) though Happy to hear your thoughts if you think thats a bad/good idea
Arcane
Arcane•2y ago
@nahtnam has reached level 8. GG!
nahtnam
nahtnamOP•2y ago
Huh I switched over and initial boot time is 2x *my measurement included me building both for prod, then opening a new tab while recording the screen and counting frames 18 frames vs 32 fames. Not the most accurate but I also felt it which is why I did that in the first place Ill stick to plasmo storage and figure out a migration structure
lab
lab•2y ago
2x faster or slower? If you want to ensure data structure for future build, ensure a consistent schema via a plain text format (Prisma's schema or trpc schema or even JSON schema). - i.e, use the tool for the job
nahtnam
nahtnamOP•2y ago
*slower
lab
lab•2y ago
If rxdb has a good schema structure I'd go for it
Validation of the stored data, so having a number instead of string doesnt throw an accidentally reset my whole application
This part generally doesn't matter much unless you're dealing with crucial financial infra OR nuke launch OR robotic supplychain IMO and if you need data validation at field level, I'd highly recommend do not try to do it on client-side, let a database do it at the engine level (postgresql), and vaildate at schema level If an error resets your whole application, I'd simply try/catch the area where that code happens and have a graceful runtime mitigation
nahtnam
nahtnamOP•2y ago
Well the main problem is my chrome extension reads from the storage and then renders the application. Id have to validate the data, and if its not valid id have to throw the value away (no way I can guess whats wrong without putting a lot of effort) and reset the values to default. This would feel like a broken experience. How would this happen? Me accidentally updating the schema to change a value from number to string on accident. I know not to do that, but im human haha As a new tab page, I cant really afford the data loading state to be 2x slower, i love how snappy my extension feels right now so Id have to go for a custom solution. I'll probably follow the same migration format thats used by redux-persist and rxdb but implement it by hand to not incur the overhead
lab
lab•2y ago
hmm, is there a way to redesign your UX to introduce more smoke and mirror/warmup UI etc... most app that load "fast" uses a couple illusion like fade-in, loading spinner, and prefetch data on hover
nahtnam
nahtnamOP•2y ago
I actually show a skeleton with suspense but at the end of the day it's noticeable. Feels wrong to fake it plus I have some good ideas on how to implement the data store
lab
lab•2y ago
By UX I mean is there a way to load the smallest amount of data needed for the first fold, then rehydrate the rest as needed? Or is the 18 frames of loading already entail smallest amount of data you need to show signs of life? Actually I have no idea if querying for 1 key in storage vs querying 10 keys in storage would tampers with perf
nahtnam
nahtnamOP•2y ago
I think the main difference is initializing all of indexed db and whatever startup costs that are associated with using the library, versus doing 2-3 localstroage reads
nahtnam
nahtnamOP•2y ago
https://rxdb.info/rx-storage.html In fact they have a performance chart at the bottom
Want results from more Discord servers?
Add your server