import { useState } from 'react'; | |
// Usage | |
function App() { | |
// Similar to useState but first arg is key to the value in local storage. | |
const [name, setName] = useLocalStorage('name', 'Bob'); | |
return ( | |
<div> | |
<input | |
type="text" | |
placeholder="Enter your name" | |
value={name} | |
onChange={e => setName(e.target.value)} | |
/> | |
</div> | |
); | |
} | |
// Hook | |
function useLocalStorage(key, initialValue) { | |
// State to store our value | |
// Pass initial state function to useState so logic is only executed once | |
const [storedValue, setStoredValue] = useState(() => { | |
try { | |
// Get from local storage by key | |
const item = window.localStorage.getItem(key); | |
// Parse stored json or if none return initialValue | |
return item ? JSON.parse(item) : initialValue; | |
} catch (error) { | |
// If error also return initialValue | |
console.log(error); | |
return initialValue; | |
} | |
}); | |
// Return a wrapped version of useState's setter function that ... | |
// ... persists the new value to localStorage. | |
const setValue = value => { | |
try { | |
// Allow value to be a function so we have same API as useState | |
const valueToStore = | |
value instanceof Function ? value(storedValue) : value; | |
// Save state | |
setStoredValue(valueToStore); | |
// Save to local storage | |
window.localStorage.setItem(key, JSON.stringify(valueToStore)); | |
} catch (error) { | |
// A more advanced implementation would handle the error case | |
console.log(error); | |
} | |
}; | |
return [storedValue, setValue]; | |
} |
This comment has been minimized.
This comment has been minimized.
I wonder if it would be an improvement to write to local storage inside |
This comment has been minimized.
This comment has been minimized.
@vitkon Local storage should only be read on the first render since I'm passing a function to |
This comment has been minimized.
This comment has been minimized.
I would also suggest wrapping localStorage calls in a try/catch block to avoid any unwanted side effects. |
This comment has been minimized.
This comment has been minimized.
|
This comment has been minimized.
This comment has been minimized.
love what you are doing here. thank you for sharing this! one suggestion, could we swap out this: const [item, setValue] = useState(() =>
window.localStorage.getItem(key) !== null
? window.localStorage.getItem(key)
: initialValue); with this, for readability: const [item, setValue] = useState(() => window.localStorage.getItem(key) || initialValue); Looking forward to more recipes. I'm already hooked after just one :) |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
@deomsj Glad you're liking it and good suggestion! Updated :) |
This comment has been minimized.
This comment has been minimized.
I like the recipe but there is an issue when using numbers and that they will convert to strings after a refresh. Maybe it is an idea to encode and decode as JSON ? |
This comment has been minimized.
This comment has been minimized.
Suggestion: Return a wrapped version of |
This comment has been minimized.
This comment has been minimized.
Suggested changes: import { useState } from 'react';
// Usage
function App() {
// Similar to useState but we pass in a key to value in local storage
// With useState: const [name, setName] = useState('Bob');
const [name, setName] = useLocalStorage('name', 'Bob');
return (
<div>
<input
type="text"
placeholder="Enter your name"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
);
}
// Hook
const useLocalStorage = (key, initialValue) => {
// The initialValue arg is only used if there is nothing in local storage ...
// ... otherwise we use the value in local storage so state persist through a page refresh.
// We pass a function to useState so local storage lookup only happens once.
const [item, setInnerValue] = useState(
() => {
if (window.localStorage.hasItem(key)) {
try {
return JSON.parse(window.localStorage.getItem(key));
} catch (error) {
// Do nothing
}
}
// Return default value if key is absent or JSON parsing fails
return initialValue;
},
);
// Return a wrapped version of useState's setter function that persists the new value to
// localStorage.
const setValue = (value) => {
setInnerValue(value);
window.localStorage.setItem(key, JSON.stringify(item));
};
return [item, setValue];
}; |
This comment has been minimized.
This comment has been minimized.
@AndyBarron So originally I had a wrapped function like you suggest but was getting some feedback that Your suggestion: const setValue = (value) => {
setInnerValue(value);
window.localStorage.setItem(key, JSON.stringify(item));
}; Or make it an effect but pass array of values that need to change for it to be called: useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(item));
},[key, item]); Any thoughts on which would be the better choice? Anyone feel free to weigh in here. |
This comment has been minimized.
This comment has been minimized.
This is sort of a tangent, but Philip Walton's article about idle until urgent was so inspiring that I can't help but think about it here https://philipwalton.com/articles/idle-until-urgent/#persisting-application-state For small cases this gist seems good, but if its being used inside a game or something that is more intensive, I wonder if it worth considering the pattern outlined under the "Persisting application state" example in the article (using an IdleQueue). I'm not suggesting that the gist change :) just thinking its worth considering before plugging this into an app. |
This comment has been minimized.
This comment has been minimized.
@gragland Another issue with |
This comment has been minimized.
This comment has been minimized.
@AndyBarron Yeah good point, okay going with the wrapped version. I changed your code slightly so that all calls to localStorage are within the try/catch. const [item, setInnerValue] = useState(() => {
try {
return window.localStorage.getItem(key)
? JSON.parse(window.localStorage.getItem(key))
: initialValue;
} catch (error) {
// Return default value if JSON parsing fails
return initialValue;
}
}); |
This comment has been minimized.
This comment has been minimized.
Proposed updates:
|
This comment has been minimized.
This comment has been minimized.
Cool lil code =) window.localStorage.setItem(key, JSON.stringify(value)); So it'll actually save the value that is reflected in the input ;) |
This comment has been minimized.
This comment has been minimized.
I also think, it should be |
This comment has been minimized.
This comment has been minimized.
@sabir-jamia is right as you save previous version of a string |
This comment has been minimized.
This comment has been minimized.
As @gustavoguichard said, it should be |
This comment has been minimized.
This comment has been minimized.
Yep, should be BTW, If you need typescript code, here you are:
|
This comment has been minimized.
This comment has been minimized.
Thanks for the feedback everyone! Just updated the recipe to fix the usage of |
This comment has been minimized.
This comment has been minimized.
Just updated so value passed to |
This comment has been minimized.
This comment has been minimized.
We have wrong behavior, if in localStorage saved string value without JSON.stringify This code will return initial value, because
We need return
|
This comment has been minimized.
This comment has been minimized.
I wish I had seen @turdiyev's post here before I had written my own TypeScript version of this This version is similar, with exceptions:
PS - @turdiyev - Thanks for your example above; I learned a couple things! |
This comment has been minimized.
This comment has been minimized.
@beausmith. You are welcome. I'm glad to help you |
This comment has been minimized.
This comment has been minimized.
Suggested change: if value is null or undefined remove it from local storage.
|
This comment has been minimized.
This comment has been minimized.
As said by @oleg-am when you try to parse a string from localStorage you get an error, but we still want to return the initalValue if it doesn't exist so we can call an external function to verify this The same occurs if you try to stringify a string, so when try to save it on localStorage verify if it already is a string const useLocalStorage = (key, initialValue) => {
const parseJsonItem = item => {
try {
return JSON.parse(item);
} catch (e) {
return item;
}
};
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
if (item) return parseJsonItem(item);
return initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = value => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(
key,
typeof valueToStore === 'string'
? valueToStore
: JSON.stringify(valueToStore),
);
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}; |
This comment has been minimized.
This comment has been minimized.
Maybe I am missing something, but why isn't it as simple as this? TypeScript: import { useCallback, useEffect, useState } from 'react';
const parseJson = <T>(str: string | null | undefined): T | undefined => {
try {
return JSON.parse(str as string);
} catch (e) {
return undefined;
}
};
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T) => void] => {
const defaultGetter = useCallback(() => {
const parsed = parseJson<T>(localStorage.getItem(key));
return parsed === undefined || parsed === null ? initialValue : parsed;
}, [ key, initialValue ]);
const [ value, setValue ] = useState<T>(defaultGetter);
useEffect(
() => localStorage.setItem(key, JSON.stringify(value)),
[ value, key ]
);
return [ value, setValue ];
};
|
This comment has been minimized.
This comment has been minimized.
Hi @gragland - Have you ran into any scenarios where local-storage is needed on more than one element? I am running into the state being updated on two elements and they both need to be isolated of each other. The error is both counters keep duplicating each others values. That shouldn't be the intended behavior |
This comment has been minimized.
This comment has been minimized.
@jephjohnson Here is a working example: https://codesandbox.io/s/zealous-bell-o6lh9 This is my use-local-storage.js:
And this is your app.js:
|
This comment has been minimized.
This comment has been minimized.
@jildertvenema - Doh! Looks like both counters are being updated at the same time. They should both be isolated. So if Increment the counter on the first one, refresh the value should persist. The second one should not duplicate the same value. Make sense? Both are independent of each other |
This comment has been minimized.
This comment has been minimized.
@jephjohnson Then why not use count1 and count2? |
This comment has been minimized.
This comment has been minimized.
In the state? Not following... |
This comment has been minimized.
This comment has been minimized.
@jephjohnson Maybe i'm missing your point but why don't u use |
This comment has been minimized.
This comment has been minimized.
Codesandbox on what your thinking would be helpful |
This comment has been minimized.
This comment has been minimized.
@jildertvenema - I am duplicating the component. What are you thinking exactly? |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
@jephjohnson i've added a stateName property to your tabs |
This comment has been minimized.
This comment has been minimized.
@jildertvenema - Epic! Solid idea :) |
This comment has been minimized.
This comment has been minimized.
There is a flaw in the way this hook tries to emulate React batches calls to Also see Why is setState giving me the wrong value?. Have a look at this example to see the problem: const BrokenCounter = () => {
const [count, setCount] = useLocalStorage("count", 0);
const handleIncrement = () => {
// count should be increased by 2
// but it will only be increased by 1 because react batches those updates
// https://reactjs.org/docs/faq-state.html#why-is-setstate-giving-me-the-wrong-value
setCount(current => current + 1);
setCount(current => current + 1);
};
return (
<div>
<p>{`The count is: ${count}`}</p>
<button onClick={handleIncrement}>Increment by 2</button>
</div>
);
} This is my suggestion for a fix: function useLocalStorage(key, initialValue) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.log(error);
return initialValue;
}
});
useEffect(() => {
try {
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
// A more advanced implementation would handle the error case
console.log(error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
} |
This comment has been minimized.
This comment has been minimized.
if you want this link
|
This comment has been minimized.
This comment has been minimized.
@gragland Would be great if this could be updated to sync across tabs. It's pretty much Inspired by donavon/use-persisted-state and useLocalStorage: hooks are nice. |
This comment has been minimized.
This comment has been minimized.
I used this code and then had I moved to this implementation instead which does not have the issue as it is using the setter returned from I would suggest to modify your code or at least list a caveat so others don't have the same issue when they stumble upon this code from a web search. |
This comment has been minimized.
This comment has been minimized.
Here's what I came up with for that in case anyone wants to do the same: import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage(key, initialValue) {
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(error);
// If error also return initialValue
return initialValue;
}
});
// Return a wrapped version of useState's setter function that persists the new value to localStorage
const setValue = useCallback(
(value) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// A more advanced implementation would handle the error case
console.warn(error);
}
},
[storedValue, setStoredValue, key],
);
useEffect(
function setUpSyncOnMount() {
function storageWatcher(ev) {
// Update our state with new value from other tab
if (!document.hasFocus() && key === ev.key && ev.oldValue !== ev.newValue) {
try {
const newValue = JSON.parse(ev.newValue);
setStoredValue(newValue);
} catch (error) {
// A more advanced implementation would handle the error case
console.warn(error);
}
}
}
window.addEventListener('storage', storageWatcher);
return function removeOnUnmount() {
window.removeEventListener('storage', storageWatcher);
};
},
[key, setStoredValue],
);
return [storedValue, setValue];
}
export default useLocalStorage; As bayareacoder suggested, if you're updating local state and not just using const [storedSortBy, setStoredSortBy] = useLocalStorage(`${id}-sort`, defaultSorted);
const documentSortBy = useRef(storedSortBy);
// ...
useEffect(
function syncSortStorage() {
// If the sort options changed on another tab/window
if (storedSortBy !== documentSortBy.current) {
setSortBy(storedSortBy);
documentSortBy.current = storedSortBy;
}
// If the sort options were changed directly by user
else if (sortBy !== storedSortBy) {
setStoredSortBy(sortBy);
documentSortBy.current = sortBy;
}
},
[sortBy, storedSortBy, setStoredSortBy, documentSortBy, setSortBy],
); |
This comment has been minimized.
This comment has been minimized.
Came accros this issue
I had to add in
I'm also rendering out from NextJS and it failed horribly attempting to access window, so had to add in
|
This comment has been minimized.
This comment has been minimized.
I also wanted to add that the current implementation doesn't work even in the TypeScript playground, adding |
This comment has been minimized.
This comment has been minimized.
add typescript type in
|
This comment has been minimized.
Is localStorage read on every rerender?