window.localStorage.setItem not executing in onkeydown callback - javascript

I am trying to implement an onkeydown event handler into my react app to close the settings screen by pressing ESC:
useEffect(() => {
document.addEventListener("keydown", handleKeyPress, false);
return () => {
document.removeEventListener("keydown", handleKeyPress, false);
};
}, [])
const saveSettings = () => {
window.localStorage.setItem("storage.ls.version", CURRENT_LOCALSTORAGE_SCHEMA_VERSION);
window.localStorage.setItem("auth.token", authInput);
window.localStorage.setItem("filter.class", classInput);
window.localStorage.setItem(
"filter.subjects",
JSON.stringify(subjectsInput.filter((v: string) => v.trim() !== ""))
);
props.dismiss();
};
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === "Escape") {
saveSettings();
}
};
The callback does execute, props.dismiss() (in saveSettings) runs just fine. But the changes don't seem to be saved to localStorage. However, when I use the same saveSettings function on a press of a button (which I have been doing already before), it works and saves as expected.
I wasn't able to find something, but is there a restriction that prevents usage of localStorage in event callbacks? Or is there another reason it doesn't work as expected?
See the comment thread under the question; here's the code that reads the localStorage:
const [authInput, setAuthInput] = useState(window.localStorage.getItem("auth.token") ?? "");
const [classInput, setClassInput] = useState(window.localStorage.getItem("filter.class") ?? "");
const [subjectsInput, setSubjectsInput] = useState(
JSON.parse(window.localStorage.getItem("filter.subjects") ?? "[]")
);

Couple of issues here that are contributing to your issue:
The handleKeyPress function is bound as the event handler for the keydown event in the first useEffect, and so it holds the context at that point in time when it runs. So say you have values in local storage already like authInput = apple, classInput = english and subjectsInput = '', and you change the value to authInput = banana, then you hit ESC, the value that will be stored in authInput will be apple again because that was the initial value of authInput when you attached handleKeyPress to the keydown event in the DOM, and will always be apple! So it 'looks' as if the value isn't changing no matter what you do. To fix this, you need to add handleKeyPress as a dependency of that first useEffect(). This function context changes with every state change, because the saveSettings() function has different values for authInput, classInput and all the other values it's referencing, whenever the state changes. So the first change you need to make is the following:
useEffect(() => {
document.addEventListener("keydown", handleKeyPress, false);
return () => {
document.removeEventListener("keydown", handleKeyPress, false);
};
}, [handleKeyPress]); // add the function as a dependency
handleKeyPress doesn't need to change if the values / functions it's referencing inside doesn't change. So use React's useCallback() to specify this relationship:
const handleKeyPress = useCallback((event) => {
if (event.key === "Escape") {
saveSettings();
}
}, [saveSettings]);
See https://reactjs.org/docs/hooks-reference.html#usecallback for more on this.
Lastly, apply the same treatment to saveSettings() because that function depends on three values:
const saveSettings = useCallback(() => {
window.localStorage.setItem("storage.ls.version", CURRENT_LOCALSTORAGE_SCHEMA_VERSION);
window.localStorage.setItem("auth.token", authInput);
window.localStorage.setItem("filter.class", classInput);
window.localStorage.setItem(
"filter.subjects",
JSON.stringify(subjectsInput.filter((v: string) => v.trim() !== ""))
);
props.dismiss();
}, [authInput, classInput, subjectsInput]);
Whenever the state of authInput, classInput or subjectsInput changes, React will run the cleanup that you specified in the useEffect() and re-run the effect with the new handler that has the correct state values in its run-time context.
If you still don't understand why this is happening, that's ok! Don't fret - I highly encourage you to read how Closures work in Javascript: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures or https://javascript.info/closure
Lastly, I want to mention some code smells for you that you might want to cleanup:
When setting the initial states for all of your state variables, wrap the expensive operation of reading from localStorage in a function so React only runs it once, instead of every re-render.
const [authInput, setAuthInput] = useState(window.localStorage.getItem("auth.token") ?? "");
// instead do this
const [authInput, setAuthInput] = useState(() => window.localStorage.getItem("auth.token") ?? "");
Is subjectsInput guaranteed to be an array when you run filter() on it in saveSettings()? You might want to account for this.

I have a react hook that might help you.
useKeyboardEvents.ts
import { useEffect } from "react";
export type UseKeyboardEventsProps = {
[key in keyof WindowEventMap]?: EventListenerOrEventListenerObject;
};
export const useKeyboardEvents = (value: UseKeyboardEventsProps) => {
useEffect(() => {
Object.entries(value).forEach((item) => {
const [key, funct] = item;
window.addEventListener(
key,
funct as EventListenerOrEventListenerObject,
false
);
});
return () => {
Object.entries(value).forEach((item) => {
const [key, funct] = item;
window.removeEventListener(
key,
funct as EventListenerOrEventListenerObject,
false
);
});
};
}, [value]);
};
Use it like follows. Note: I used keydown like in your code, but I think it would be better to listen to keyup. (replace your useEffect with the following)
useKeyboardEvents({
keydown: (event: any) => {
if (event.key === 'Escape') {
saveSettings();
}
}
});

Related

Functional component with React.memo() still rerenders

I have a button component that has a button inside that has a state passed to it isActive and a click function. When the button is clicked, the isActive flag will change and depending on that, the app will fetch some data. The button's parent component does not rerender. I have searched on how to force stop rerendering for a component and found that React.memo(YourComponent) must do the job but still does not work in my case. It also make sense to pass a check function for the memo function whether to rerender or not which I would set to false all the time but I cannot pass another argument to the function. Help.
button.tsx
interface Props {
isActive: boolean;
onClick: () => void;
}
const StatsButton: React.FC<Props> = ({ isActive, onClick }) => {
useEffect(() => {
console.log('RERENDER');
}, []);
return (
<S.Button onClick={onClick} isActive={isActive}>
{isActive ? 'Daily stats' : 'All time stats'}
</S.Button>
);
};
export default React.memo(StatsButton);
parent.tsx
const DashboardPage: React.FC = () => {
const {
fetchDailyData,
fetchAllTimeData,
} = useDashboard();
useEffect(() => {
fetchCountry();
fetchAllTimeData();
// eslint-disable-next-line
}, []);
const handleClick = useEventCallback(() => {
if (!statsButtonActive) {
fetchDailyData();
} else {
fetchAllTimeData();
}
setStatsButtonActive(!statsButtonActive);
});
return (
<S.Container>
<S.Header>
<StatsButton
onClick={handleClick}
isActive={statsButtonActive}
/>
</S.Header>
</S.Container>
)
}
fetch functions are using useCallback
export const useDashboard = (): Readonly<DashboardOperators> => {
const dispatch: any = useDispatch();
const fetchAllTimeData = useCallback(() => {
return dispatch(fetchAllTimeDataAction());
}, [dispatch]);
const fetchDailyData = useCallback(() => {
return dispatch(fetchDailyDataAction());
}, [dispatch]);
return {
fetchAllTimeData,
fetchDailyData,
} as const;
};
You haven't posted all of parent.tsx, but I assume that handleClick is created within the body of the parent component. Because the identity of the function will be different on each rendering of the parent, that causes useMemo to see the props as having changed, so it will be re-rendered.
Depending on if what's referenced in that function is static, you may be able to use useCallback to pass the same function reference to the component on each render.
Note that there is an RFC for something even better than useCallback; if useCallback doesn't work for you look at how useEvent is defined for an idea of how to make a better static function reference. It looks like that was even published as a new use-event-callback package.
Update:
It sounds like useCallback won't work for you, presumably because the referenced variables used by the callback change on each render, causing useCallback to return different values, thus making the prop different and busting the cache used by useMemo. Try that useEventCallback approach. Just to illustrate how it all works, here's a naive implementation.
function useEventCallback(fn) {
const realFn = useRef(fn);
useEffect(() => {
realFn.current = fn;
}, [fn]);
return useMemo((...args) => {
realFn.current(...args)
}, []);
}
This useEventCallback always returns the same memoized function, so you'll pass the same value to your props and not cause a re-render. However, when the function is called it calls the version of the function passed into useEventCallback instead. You'd use it like this in your parent component:
const handleClick = useEventCallback(() => {
if (!statsButtonActive) {
fetchDailyData();
} else {
fetchAllTimeData();
}
setStatsButtonActive(!statsButtonActive);
});

Why function not passed as reference in the eventlistener in ReactJS

I am using functional components in ReactJS.
const [showState, setState] = useState();
useEffect(() => {
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
}
}, []);
function handleClick(event) {
console.log(showState);
};
function toggleState() {
setState(true);
}
return ( <
button onClick = {
toggleState
} > Toggle < /button>
)
When I toggle the state by pressing the button, after that when I trigger click event (handleClick function) by clicking somewhere, Console shows "undefined". But isn't the handleClick function passed as reference to the click eventListener, when the state change through toggle, it should display "true" in the console.
When you create function handleClick it capture value of showState via closure, and this value is undefined on the first render. But when you call toggleState, React doesn't change value of showState, instead it re-renders component with new value and a new instance of handleClick is created, with captured new value. However global listener still store old version of a function with undefined.
You can achieve desired result either with useRef or by wrapping handleClick into useCallback and adding it as a dependency to useEffect.
useRef allow you to persist reference between renders:
const showState = useRef();
function handleClick(event) {
console.log(showState.current);
};
function toggleState() {
showState.current = true;
}
However, you should keep in mind that useRef doesn't trigger re-render.
And with useCallback:
const [showState, setState] = useState();
const handleClick = useCallback(() => {
console.log(showState);
}, [showState]);
useEffect(() => {
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
}
}, [handleClick]);
This way each time showState changes new instance of handleClick created, with new value in a closure, and useEffect is triggered re-attaching event listener.

Assigning a type to an event handler in a custom hook

I have a custom useEventListener hook that I've derived from here, and I just started learning Typescript, so I decided to rewrite it into a typed version.
Here's the hook that I have,
const useEventListener = (
eventName: string,
handler: any,
) => {
const savedHandler: any = useRef()
useEffect(() => {
savedHandler.current = handler
}, [handler])
useEffect(() => {
const eventListener: any = (event: KeyboardEvent) => savedHandler.current(event)
window.addEventListener(eventName, eventListener);
return () => {
window.removeEventListener(eventName, eventListener)
}
}, [eventName])
}
And I use it as,
const keyPressHandler = useCallback(({ key }: KeyboardEvent) => {
if (key === 'ArrowUp')
// do something
else if (key === 'ArrowDown')
// do something else
})
useEventListener('keydown', keyPressHandler)
It's a relatively simple setup, but I've been banging my head on the typing aspect. Getting rid of the any in
const savedHandler: any = useRef()
or in
const eventListener: any = (. . .
makes the compiler complain, and I don't know how to get around this. What type is savedHandler in this case? And for const eventListener, why does it even need a type?
For the saveHandler you can type the useRef as followed:
const savedHandler = useRef<YOURHTMLELEMENTTYPE>();
For YOURHTMLELEMENTTYPE you can choose any of the following, it depends on the the element your referring to.
For the eventListener I would recommend this post. I think this post describes your exact problem.

Execution order between React's useEffect and DOM event handler

I wrote a custom context provider in the below which holds application settings as a Context as well as save it in localStorage (thanks to a post by Alex Krush).
I added initialized flag to avoid saving the value fetched from localStorage right after the component is mounted (useEffect will be run at the timing of componentDidMount and try to write the fetched value to the storage).
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
const storageKey = 'name';
const defaultValue = 'John Doe';
const initializer = (initialValue) => localStorage.getItem(storageKey) || initialValue;
const reducer = (value, newValue) => newValue;
const CachedContext = React.createContext();
const CachedContextProvider = (props) => {
const [value, setValue] = useReducer(reducer, defaultValue, initializer);
const initialized = useRef(false);
// save the updated value as a side-effect
useEffect(() => {
if (initialized.current) {
localStorage.setItem(storageKey, value);
} else {
initialized.current = true; // skip saving for the first time
}
}, [value]);
return (
<CachedContext.Provider value={[value, setValue]}>
{props.children}
</CachedContext.Provider>
);
};
Usage:
const App = (props) => {
return <CachedContextProvider><Name name='Jane Doe' /></CachedContextProvider>;
}
const Name = (props) => {
const [name, setName] = useContext(CachedContext);
useEffect(() => {
setName(props.name);
}, [props.name]);
}
Then, I'd like to make my custom context detect changes to the target storage made by another window. I added handleStorageEvent to CachedContextProvider for listening storage events:
// re-initialize when the storage has been modified by another window
const handleStorageEvent = useCallback((e) => {
if (e.key === storageKey) {
initialized.current = false; // <-- is it safe???
setValue(initializer(defaultValue));
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
};
}
}, []);
My concern is whether I can reset initialized to false safely for avoid writing back the fetched value. I'm worried about the following case in the multi-process setting:
Window 1 runs setValue('Harry Potter')
Window 2 runs setValue('Harry Potter')
Window 2 runs localStorage.setItem in response to update on value
handleStorageEvent in Window 1 detects the change in storage and re-initialize its initialized and value as false and 'Harry Potter'
Window 1 try to run localStorage.setItem, but it does nothing because value is already set as 'Harry Potter' by Window 2 and React may judge there is no changes. As a result, initialized will be kept as false
Window 1 runs setValue('Ron Weasley'). It updates value but does not save it because initialized === false. It has a chance to lose the value set by the application
I think it is related to the execution order between React's useEffect and DOM event handler. Does anyone know how to do it right?
I would probably add some sort of test to see what happens in every possible scenario.
However, this is my theory: In step 5, window 1 will not try to run localStorage.setItem (as you said), since initialized was just set to false. It will instead set initialized to true. Step 6 should therefore work as expected, and this shouldn't be an issue.
I discussed the problem with my colleague and finally found a solution. He pointed out that new React Fiber engine should not ensure the order of execution of side-effects, and suggested adding a revision number to the state.
Here is an example. The incremented revision will always invoke useEffect even if the set value is not changed. Subscribers obtain state.value from Provider and don't need to concern about the underlying revision.
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
const storageKey = 'name';
const defaultValue = 'John Doe';
const orDefault(value) = (value) =>
(typeof value !== 'undefined' && value !== null) ? value : defaultValue;
const initializer = (arg) => ({
value: orDefault(localStorage.getItem(storageKey)),
revision: 0,
});
const reducer = (state, newValue) => ({
value: newValue,
revision: state.revision + 1,
});
const useCachedValue = () => {
const [state, setState] = useReducer(reducer, null, initializer);
const initialized = useRef(false);
// save the updated value as a side-effect
useEffect(() => {
if (initialized.current) {
localStorage.setItem(storageKey, state.value);
} else {
// skip saving just after the (re-)initialization
initialized.current = true;
}
}, [state]);
// re-initialize when the storage has been modified by another window
const handleStorageEvent = useCallback((e) => {
if (e.key === null || e.key === storageKey) {
initialized.current = false;
setState(orDefault(e.newValue));
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
};
}
}, []);
return [state.value, setState];
};
const Context = React.createContext();
const Provider = (props) => {
const cachedValue = useCachedValue();
return (
<Context.Provider value={cachedValue}>
{props.children}
</Context.Provider>
);
};

REACT: Log all methods executed by an event, specifically touchstart and touchmove

As mentioned in the title, I am using React building an application with flux methodology in mind. How do I log all of the methods launched by a touchmove or touchstart event? Can't seem to find something like this on google...
I think the most value for least amount of code would come from creating your own middleware that looks for events in the action objects as they pass through the dispatch function. Here is some sample code below that works in react-create-app and can be easily modified to suit your purpose:
function createLogger(typeOfEvent) {
return () => (next) => (action) => {
let e;
function isEvent(value) {
return value.hasOwnProperty('bubbles')
}
Object.values(action).map(value => {
if (isEvent(value)) {
e = value;
}
})
if (e.type === typeOfEvent) {
console.log(e);
}
let returnValue = next(action)
return returnValue
}
}
const logger = createLogger('click')
export const store = createStore(reducer, applyMiddleware(logger));
Then make sure you dispatch all your actions with the appropriate event as a value somewhere:
<div className="App" onClick={(e) => store.dispatch({type: 'ACTION', e })}>

Categories