i have some issues dealing with a simple case in my redux-react app: i want to reset an input text after an asynchronous operation ignited by a button.
Let’s say we have an input text in which you put a text and this is passed through a onClick event to a dispatch action.
This action contacts a server and after the server response i want to reset the input field.
I’ve implemented a number of solutions (i’m using redux thunk) to this problem but i’m not sure if they are hacky ways to solve it, let me show you:
1) Presentational component (the input field) implements a reset method that is passed as a value to the onClick method.
export default React.createClass({
reset: function () {
this.setState({searchText: ''})
},
getInitialState: function () {
return {
searchText: ''
}
},
render: function () {
return (
<div>
<TextField
value={this.state.searchText}
onChange={e => this.setState({ searchText: e.target.value })}
/>
<RaisedButton
onClick={this.props.startSearch.bind(null,
this.state.searchText,
this.reset)} // ===> HERE THE RESET FUNCTION IS PASSED
/>
</div>
)
}
})
The container dispatches the action and then calls the reset method.
const mapDispatchToProps = (dispatch) => {
return {
startSearch: (searchText, reset) => {
dispatch(actions.startSearch(searchText))
.then(() => reset())
}
}
}
2) Using ref (https://facebook.github.io/react/docs/refs-and-the-dom.html)
The container gets a reference to its child and calls reset through it
const SearchUserContainer = React.createClass({
startSearch: (searchText) => {
dispatch(actions.startSearch(searchText))
.then(() => this.child.reset())
},
render: function () {
return (
<SearchUser {...this.props} ref={(child) => { this.child = child; }}/>
)
}
})
3) The Redux Way.
searchText is managed by the store thus the action dispatched triggers a resolver that reset the searchText value, the container updates its child and we are done, well… almost:
the presentational component is a controlled component (https://facebook.github.io/react/docs/forms.html#controlled-components) that means it manages the input text as an internal state, i think we have to find a way to make the two ‘state managers’ coexist.
I wrote this code to manage the internal state and the state coming from redux, in few words the presentational gets the initial value from redux, then updates it in the onChange event and it’s ready to receive updates from redux thanks to componentWillReceiveProps.
export default React.createClass({
getInitialState: function () {
return {
searchText: this.props.searchText ==> REDUX
}
},
componentWillReceiveProps: function (nextProps) {
this.setState({
searchText: nextProps.searchText ==> REDUX
})
},
render: function () {
return (
<div>
<TextField
value={this.state.searchText}
onChange={e => this.setState({ searchText: e.target.value })}
/>
<RaisedButton
onClick={this.props.startSearch.bind(null, this.state.searchText)}
/>
</div>
)
}
})
4) Redux-Form
To complete the picture i link the redux-form options to do that
http://redux-form.com/6.5.0/docs/faq/HowToClear.md/
What do you think about those ideas?
Thanks.
Go the Redux way, except go all the way: remove the internal state from your component completely and let Redux handle it (might as well make your component a pure-functional component too):
Component:
import { connect } from 'redux';
import { actions } from 'actionCreators';
const ControlledInputComponent = (props) => {
return (
<div>
<TextField
value={this.props.searchText}
onChange={e => this.props.setSearchText(e.target.value)}
/>
<RaisedButton
onClick={this.props.startSearch}
/>
</div>
);
};
const mapStateToProps = (state) => {
return { searchText: state.searchText };
};
const mapDispatchToProps = (dispatch) => {
return {
setSearchText: (txt) => { dispatch(actions.setSearchText(txt)); },
startSearch: () => { dispatch(actions.search()); }
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ControlledInputComponent);
Action creator:
export const actions = {
setSearchText: (txt) => ({ type: 'setText', data: txt }),
//here's where the thunk comes in
//make sure you have redux-thunk and add it as a middleware when setting up the store, etc.
search: () => {
return (dispatch) => {
//use fetch or whatever to run your search (this is a simplified example)
fetch(/* your url here */).then(() => {
//presumably a success condition
//handle the search results appropriately...
//dispatch again to reset the search text
dispatch(actions.setSearchText(null);
});
};
}
};
Reducer:
const reducer = (state = { searchText: null }, action) => {
if (!action || !action.type) return state;
switch (action.type) {
//you should really define 'setText' as a constant somewhere
//so you can import it and not have to worry about typos later
case 'setText':
return Object.assign({}, state, { searchText: action.data });
default:
return state;
}
};
export default reducer;
Hopefully that helps. Good luck!
Related
What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.
import { useEffect, useReducer } from "react";
let elements: any[] = [];
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
elements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
console.log('elements 1:', elements)
}, [state]);
console.log('elemets 2:', elements)
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.
The strange thing is that the problem doesn't happen if you comment out this if-statement:
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.
Question: What could be the problem, and how to fix it? I want to render the current length of elements.
Live code:
It's happening because of the elements variable is not a state, so it's not reactive.
Create a state for the elements:
const [elements, setElements] = useState<HTMLButtonElement[]>([])
And use this state to handle the elements.
import { useEffect, useReducer, useState } from "react";
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
const [elements, setElements] = useState<HTMLButtonElement[]>([])
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
let newElements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
setElements(newElements)
console.log("elements 1:", elements?.length);
}, [state]);
console.log("elemets 2:", elements?.length);
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements?.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
Your useEffect() runs after your component has rendendered. So the sequence is:
You type something into input, that triggers handleInputChange
handleInputChange then updates your state using updateState()
The state update causes a rerender, so App is called App()
console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
App returns the new JSX
Your useEffect() callback runs, updating elements
Notice how we're only updating the elements after you've rerendered and App has been called.
The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.
With that being said, I would make the following updates:
Add elements to your state
Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/#babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useReducer} = React;
const App = () => {
const initialState = {keyEvent: {}, value: "Initial state", elements: []};
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates}),
initialState
);
function searchDOM(value) {
const selectors = "button";
const pattern = new RegExp(value === "" ? "^$" : value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
return nodeWithText?.textContent?.match(pattern);
}
return false;
}
);
}
function handleInputChange(event) {
updateState({
value: event.target.value,
elements: searchDOM(event.target.value)
});
}
function isCommand(event) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event) {
if (isCommand(event)) {
updateState({
keyEvent: event
});
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
console.log("elements:", state.elements.length);
return (
<div>
<input id="input" type="text" onChange={handleInputChange} value={state.value} />
<div id="count">{state.elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
ReactDOM.createRoot(document.body).render(<App />);
</script>
useEffect triggered after react completed its render phase & flush the new changes to the DOM.
In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )
So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.
Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.
Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,
let elements = useMemo(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
})
}, [state])
Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo
My UI was working fine until it was using a class component. Now I am refactoring it to a functional component.
I have to load my UI based on the data I receive from an API handler. My UI will reflect the state of the camera which is present inside a room. Every time the camera is turned on or off from the room, I should receive the new state from the API apiToGetCameraState.
I want the console.log present inside the registerVideoStateUpdateHandlerWrapper to print both on UI load for the first time and also to load every time the video state is changed in the room. However, it doesn't work when the UI is loaded for the first time.
This is how my component looks like:
const Home: React.FunctionComponent<{}> = React.memo(() => {
const [video, setToggleVideo] = React.useState(true);
const registerVideoStateUpdateHandlerWrapper = React.useCallback(() => {
apiToGetCameraState(
(videoState: boolean) => {
// this log does not show up when the UI is loaded for the first time
console.log(
`Video value before updating the state: ${video} and new state is: ${videoState} `
);
setToggleVideo(videoState);
}
);
}, [video]);
React.useEffect(() => {
//this is getting called when the app loads
alert(`Inside use effect for Home component`);
registerVideoStateUpdateHandlerWrapper ();
}, [registerVideoStateUpdateHandlerWrapper ]);
return (
<Grid>
<Camera
isVideoOn={video}
/>
</Grid>
);
});
This was working fine when my code was in class component. This is how the class component looked like.
class Home extends Component {
registerVideoStateUpdateHandlerWrapper = () => {
apiToGetCameraState((videoState) => {
console.log(`ToggleVideo value before updating the state: ${this.state.toggleCamera} and new state is: ${videoState}`);
this.setStateWrapper(videoState.toString());
})
}
setStateWrapper = (toggleCameraUpdated) => {
console.log("Inside setStateWrapper with toggleCameraUpdated:" + toggleCameraUpdated);
this.setState({
toggleCamera: (toggleCameraUpdated === "true" ) ? "on" : "off",
});
}
constructor(props) {
super(props);
this.state = {
toggleCamera: false,
};
}
componentDidMount() {
console.log(`Inside componentDidMount with toggleCamera: ${this.state.toggleCamera}`)
this.registerVideoStateUpdateHandlerWrapper ();
}
render() {
return (
<div>
<Grid>
<Camera isVideoOn={this.state.toggleCamera} />
</Grid>
);
}
}
What all did I try?
I tried removing the useCallback in the registerVideoStateUpdateHandlerWrapper function and also the dependency array from React.useEffect and registerVideoStateUpdateHandlerWrapper. It behaved the same
I tried updating the React.useEffect to have the code of registerVideoStateUpdateHandlerWrapper in it but still no success.
Move registerVideoStateUpdateHandlerWrapper() inside the useEffect() callback like this. If you want to log the previous state when the state changes, you should use a functional update to avoid capturing the previous state through the closure:
const Home = () => {
const [video, setVideo] = useState(false);
useEffect(() => {
console.log('Inside useEffect (componentDidMount)');
const registerVideoStateUpdateHandlerWrapper = () => {
apiToGetCameraState((videoState) => {
setVideo((prevVideo) => {
console.log(`Video value before updating the state: ${prevVideo} and new state is: ${videoState}`);
return videoState;
});
});
};
registerVideoStateUpdateHandlerWrapper();
}, []);
return (
<Grid>
<Camera isVideoOn={video} />
</Grid>
);
};
When you no longer actually need to log the previous state, you should simplify registerVideoStateUpdateHandlerWrapper() to:
const registerVideoStateUpdateHandlerWrapper = () => {
apiToGetCameraState((videoState) => {
setVideo(videoState);
});
};
import React from 'react'
const Home = () => {
const [video, setVideo] = useState(null);
//default video is null, when first load video will change to boolean, when the Camera component will rerender
const registerVideoStateUpdateHandlerWrapper = () => {
apiToGetCameraState((videoState) => {
setVideo(videoState);
});
};
useEffect(() => {
registerVideoStateUpdateHandlerWrapper();
}, []);
return (
<Grid>
<Camera isVideoOn={video} />
</Grid>
);
};
export default Home
componentDidMount() === useEffect()
'useEffect' => import from 'react'
// componentDidMount()
useEffect(() => {
// Implement your code here
}, [])
// componentDidUpdate()
useEffect(() => {
// Implement your code here
}, [ update based on the props, state in here if you mention ])
e.g:
const [loggedIn, setLoggedIn] = useState(false);
useEffect(() => {
// Implement the code here
}, [ loggedIn ]);
the above code will act as equivalent to the componentDidUpdate based on 'loggedIn' state
Below is the HOC and it is connected to redux store too. The WrappedComponent function is not fetching the redux state on change of storedata. What could be wrong here?
export function withCreateHOC<ChildProps>(
ChildComponent: ComponentType,
options: WithCreateButtonHOCOptions = {
title: 'Create',
},
) {
function WrappedComponent(props: any) {
const { createComponent, title } = options;
const [isOpen, setisOpen] = useState(false);
function onCreateClick() {
setisOpen(!isOpen);
Util.prevDefault(() => setisOpen(isOpen));
}
return (
<div>
<ChildComponent {...props} />
<div>
<Component.Button
key={'add'}
big={true}
round={true}
primary={true}
onClick={Util.prevDefault(onCreateClick)}
className={'float-right'}
tooltip={title}
>
<Component.Icon material={'add'} />
</Component.Button>
</div>
<OpenDrawerWithClose
open={isOpen}
title={title}
setisOpen={setisOpen}
createComponent={createComponent}
/>
</div>
);
}
function mapStateToProps(state: any) {
console.log('HOC mapStateToProps isOpen', state.isOpen);
return {
isOpen: state.isOpen,
};
}
// Redux connected;
return connect(mapStateToProps, {})(WrappedComponent);
}
Expecting isOpen to be used from ReduxStore and update the same with WrappedComponent here. By any chance this should be changed to class component?
The above HOC is used as:
export const Page = withCreateHOC(
PageItems,
{
createComponent: <SomeOtherComponent />,
title: 'Create',
},
);
Overview
You don't want isOpen to be a local state in WrappedComponent. The whole point of this HOC is to access isOpen from your redux store. Note that nowhere in this code are you changing the value of your redux state. You want to ditch the local state, access isOpen from redux, and dispatch an action to change isOpen in redux.
Additionally we've got to replace some of those anys with actual types!
It seems a little suspect to me that you are passing a resolved JSX element rather than a callable component as createComponent (<SomeOtherComponent /> vs SomeOtherComponent), but whether that is correct or a mistake depends on what's in your OpenDrawerWithClose component. I'm going to assume it's correct as written here.
There's nothing technically wrong with using connect, but it feels kinda weird to use an HOC inside of an HOC so I am going to use the hooks useSelector and useDispatch instead.
Step By Step
We want to create a function that takes a component ComponentType<ChildProps> and some options WithCreateButtonHOCOptions. You are providing a default value for options.title so we can make it optional. Is options.createComponent optional or required?
interface WithCreateButtonHOCOptions {
title: string;
createComponent: React.ReactNode;
}
function withCreateHOC<ChildProps>(
ChildComponent: ComponentType<ChildProps>,
options: Partial<WithCreateButtonHOCOptions>
) {
We return a function that takes the same props, but without isOpen or toggleOpen, if those were properties of ChildProps.
return function (props: Omit<ChildProps, 'isOpen' | 'toggleOpen'>) {
We need to set defaults for the options in the destructuring step in order to set only one property.
const { createComponent, title = 'Create' } = options;
We access isOpen from the redux state.
const isOpen = useSelector((state: { isOpen: boolean }) => state.isOpen);
We create a callback that dispatches an action to redux -- you will need to handle this in your reducer. I am dispatching a raw action object {type: 'TOGGLE_OPEN'}, but you could make an action creator function for this.
const dispatch = useDispatch();
const toggleOpen = () => {
dispatch({type: 'TOGGLE_OPEN'});
}
We will pass these two values isOpen and toggleOpen as props to ChildComponent just in case it want to use them. But more importantly, we can use them as click handlers on your button and drawer components. (Note: it looks like drawer wants a prop setIsOpen that takes a boolean, so you may need to tweak this a bit. If the drawer is only shown when isOpen is true then just toggling should be fine).
Code
function withCreateHOC<ChildProps>(
ChildComponent: ComponentType<ChildProps>,
options: Partial<WithCreateButtonHOCOptions>
) {
return function (props: Omit<ChildProps, 'isOpen' | 'toggleOpen'>) {
const { createComponent, title = 'Create' } = options;
const isOpen = useSelector((state: { isOpen: boolean }) => state.isOpen);
const dispatch = useDispatch();
const toggleOpen = () => {
dispatch({ type: 'TOGGLE_OPEN' });
}
return (
<div>
<ChildComponent
{...props as ChildProps}
toggleOpen={toggleOpen}
isOpen={isOpen}
/>
<div>
<Component.Button
key={'add'}
big={true}
round={true}
primary={true}
onClick={toggleOpen}
className={'float-right'}
tooltip={title}
>
<Component.Icon material={'add'} />
</Component.Button>
</div>
<OpenDrawerWithClose
open={isOpen}
title={title}
setisOpen={toggleOpen}
createComponent={createComponent}
/>
</div>
);
}
}
This version is slightly better because it does not have the as ChildProps assertion. I don't want to get too sidetracked into the "why" but basically we need to insist that if ChildProps takes an isOpen or toggleOpen prop, that those props must have the same types as the ones that we are providing.
interface AddedProps {
isOpen: boolean;
toggleOpen: () => void;
}
function withCreateHOC<ChildProps>(
ChildComponent: ComponentType<Omit<ChildProps, keyof AddedProps> & AddedProps>,
options: Partial<WithCreateButtonHOCOptions>
) {
return function (props: Omit<ChildProps, keyof AddedProps>) {
const { createComponent, title = 'Create' } = options;
const isOpen = useSelector((state: { isOpen: boolean }) => state.isOpen);
const dispatch = useDispatch();
const toggleOpen = () => {
dispatch({ type: 'TOGGLE_OPEN' });
}
return (
<div>
<ChildComponent
{...props}
toggleOpen={toggleOpen}
isOpen={isOpen}
/>
<div>
<Component.Button
key={'add'}
big={true}
round={true}
primary={true}
onClick={toggleOpen}
className={'float-right'}
tooltip={title}
>
<Component.Icon material={'add'} />
</Component.Button>
</div>
<OpenDrawerWithClose
open={isOpen}
title={title}
setisOpen={toggleOpen}
createComponent={createComponent}
/>
</div>
);
}
}
Playground Link
I can't quite figure out the optimal way to use useReducer hook for data management. My primary goal is to reduce (heh) the boilerplate to minimum and maintain code readability, while using the optimal approach in terms of performance and preventing unnecessary re-renders.
The setup
I have created a simplified example of my app, basically it's a <List /> component - a list of items with possibility to select them, and a <Controls /> component which can switch item groups and reload the data set.
List.js
import React, { memo } from "react";
const List = ({ items, selected, selectItem, deselectItem }) => {
console.log("<List /> render");
return (
<ul className="List">
{items.map(({ id, name }) => (
<li key={`item-${name.toLowerCase()}`}>
<label>
<input
type="checkbox"
checked={selected.includes(id)}
onChange={(e) =>
e.target.checked ? selectItem(id) : deselectItem(id)
}
/>
{name}
</label>
</li>
))}
</ul>
);
};
export default memo(List);
Controls.js
import React, { memo } from "react";
import { ItemGroups } from "./constants";
const Controls = ({ group, setGroup, fetchItems }) => {
console.log("<Controls /> render");
return (
<div className="Controls">
<label>
Select group
<select value={group} onChange={(e) => setGroup(e.target.value)}>
<option value={ItemGroups.PEOPLE}>{ItemGroups.PEOPLE}</option>
<option value={ItemGroups.TREES}>{ItemGroups.TREES}</option>
</select>
</label>
<button onClick={() => fetchItems(group)}>Reload data</button>
</div>
);
};
export default memo(Controls);
App.js
import React, { useEffect, useReducer } from "react";
import Controls from "./Controls";
import List from "./List";
import Loader from "./Loader";
import { ItemGroups } from "./constants";
import {
FETCH_START,
FETCH_SUCCESS,
SET_GROUP,
SELECT_ITEM,
DESELECT_ITEM
} from "./constants";
import fetchItemsFromAPI from "./api";
import "./styles.css";
const itemsReducer = (state, action) => {
const { type, payload } = action;
console.log(`reducer action "${type}" dispatched`);
switch (type) {
case FETCH_START:
return {
...state,
isLoading: true
};
case FETCH_SUCCESS:
return {
...state,
items: payload.items,
isLoading: false
};
case SET_GROUP:
return {
...state,
selected: state.selected.length ? [] : state.selected,
group: payload.group
};
case SELECT_ITEM:
return {
...state,
selected: [...state.selected, payload.id]
};
case DESELECT_ITEM:
return {
...state,
selected: state.selected.filter((id) => id !== payload.id)
};
default:
throw new Error("Unknown action type in items reducer");
}
};
export default function App() {
const [state, dispatch] = useReducer(itemsReducer, {
items: [],
selected: [],
group: ItemGroups.PEOPLE,
isLoading: false
});
const { items, group, selected, isLoading } = state;
const fetchItems = (group) => {
dispatch({ type: FETCH_START });
fetchItemsFromAPI(group).then((items) =>
dispatch({
type: FETCH_SUCCESS,
payload: { items }
})
);
};
const setGroup = (group) => {
dispatch({
type: SET_GROUP,
payload: { group }
});
};
const selectItem = (id) => {
dispatch({
type: SELECT_ITEM,
payload: { id }
});
};
const deselectItem = (id) => {
dispatch({
type: DESELECT_ITEM,
payload: { id }
});
};
useEffect(() => {
console.log("use effect on group change");
fetchItems(group);
}, [group]);
console.log("<App /> render");
return (
<div className="App">
<Controls {...{ group, fetchItems, setGroup }} />
{isLoading ? (
<Loader />
) : (
<List {...{ items, selected, selectItem, deselectItem }} />
)}
</div>
);
}
Here's the complete sandbox.
The state is managed in a reducer, because I need different parts of state to work and change together. For example, reset selected items on group change (because it makes no sense to keep selections between different data sets), set loaded items and clear loading state on data fetch success, etc. The example is intentionally simple, but in reality there're many dependencies between different parts of state (filtering, pagination, etc.), which makes reducer a perfect tool to manage it - in my opinion.
I've created helper functions to perform different actions (for ex., to reload items or to select/deselect). I could just pass down the dispatch to children and create action objects there, but this turns everything into a mess really quickly, esp. when multiple components must perform same actions.
Problem 1
Passing down reducer action functions to child components causes them to re-render on any reducer update.
Case 1: When I select an item in <List />, the <Controls /> is
re-rendered.
Case 2: When I reload the data on Reload button click, the <Controls /> is
re-rendered.
In both cases, the <Controls /> only actually depends on group prop to render, so when it stays the same - the component should not re-render.
I've investigated it and this happens because on each <App /> re-render these action functions are re-created and treated as new prop values for child components, so for React it's simple: new props => new render.
Not ideal solution to this is to wrap all action functions in useCallback, with dispatch as a dependency, but this looks like a hack to me.
const setGroup = useCallback(
(group) => {
dispatch({
type: SET_GROUP,
payload: { group }
});
},
[dispatch]
);
In a simple example it does not look too bad, but when you have dozens of possible actions, all wrapped in useCallback, with deps arrays - that does not seem right.
And it requires to add even more deps to useEffect (which is another problem).
Here's a "fixed" version with useCallback.
Problem 2
I cannot fully extract reducer action functions outside the <App /> component, because in the end they must be used inside a React component with the dispatch (because it's a hook).
I can of course extract them to a separate module and pass dispatch as a first argument:
in actions.js
// ...
export const fetchItems = (dispatch, group) => {
dispatch({ type: FETCH_START });
fetchItemsFromAPI(group).then((items) =>
dispatch({
type: FETCH_SUCCESS,
payload: { items }
})
);
};
// ...
and then in child components do this:
import { fetchItems } from './actions';
const Child = ({ dispatch, group }) => {
fetchItems(dispatch, group);
// ...
};
and reduce my <App /> to this:
// ...
const App = () => {
const [{ items, group, selected, isLoading }, dispatch] = useReducer(
itemsReducer,
itemReducerDefaults
);
useEffect(() => {
fetchItems(dispatch, group);
}, [group, dispatch]);
return (
<div className="App">
<Controls {...{ group, dispatch }} />
{isLoading ? <Loader /> : <List {...{ items, selected, dispatch }} />}
</div>
);
};
but then I have to pass around the dispatch (minor issue) and always have it in arguments list. On the other hand, it fixes the Problem 1 as well, as dispatch does not change between renders.
Here's a sandbox with actions and reducer extracted.
But is it optimal, or maybe I should use some entirely different approach?
So, how do you guys use it? The React docs and guides are nice and clean with counter increments and ToDo lists, but how do you actually use it in real world apps?
React-redux works by also wrapping all the actions with a call to dispatch; this is abstracted away when using the connect HOC, but still required when using the useDispatch hook. Async actions typically have a function signature (...args) => dispatch => {} where the action creator instead returns a function that accepts the dispatch function provided by redux, but redux requires middleware to handle these. Since you are not actually using Redux you'd need to handle this yourself, likely using a combination of both patterns to achieve similar usage.
I suggest the following changes:
De-couple and isolate your action creators, they should be functions that return action objects (or asynchronous action functions).
Create a custom dispatch function that handles asynchronous actions.
Correctly log when a component renders (i.e. during the commit phase in an useEffect hook and not during any render phase in the component body. See this lifecycle diagram.
Pass the custom dispatch function to children, import actions in children... dispatch actions in children. How to avoid passing callbacks down.
Only conditionally render the Loader component. When you render one or the other of Loader and List the other is unmounted.
Actions (actions.js)
import {
FETCH_START,
FETCH_SUCCESS,
SET_GROUP,
SELECT_ITEM,
DESELECT_ITEM
} from "./constants";
import fetchItemsFromAPI from "./api";
export const setGroup = (group) => ({
type: SET_GROUP,
payload: { group }
});
export const selectItem = (id) => ({
type: SELECT_ITEM,
payload: { id }
});
export const deselectItem = (id) => ({
type: DESELECT_ITEM,
payload: { id }
});
export const fetchItems = (group) => (dispatch) => {
dispatch({ type: FETCH_START });
fetchItemsFromAPI(group).then((items) =>
dispatch({
type: FETCH_SUCCESS,
payload: { items }
})
);
};
useAsyncReducer.js
const asyncDispatch = (dispatch) => (action) =>
action instanceof Function ? action(dispatch) : dispatch(action);
export default (reducer, initialArg, init) => {
const [state, syncDispatch] = React.useReducer(reducer, initialArg, init);
const dispatch = React.useMemo(() => asyncDispatch(syncDispatch), []);
return [state, dispatch];
};
Why doesn't useMemo need a dependency on useReducer dispatch function?
useReducer
Note
React guarantees that dispatch function identity is stable and won’t
change on re-renders. This is why it’s safe to omit from the useEffect
or useCallback dependency list.
We want to also provide a stable dispatch function reference.
App.js
import React, { useEffect } from "react";
import useReducer from "./useAsyncReducer";
import Controls from "./Controls";
import List from "./List";
import Loader from "./Loader";
import { ItemGroups } from "./constants";
import {
FETCH_START,
FETCH_SUCCESS,
SET_GROUP,
SELECT_ITEM,
DESELECT_ITEM
} from "./constants";
import { fetchItems } from "./actions";
export default function App() {
const [state, dispatch] = useReducer(itemsReducer, {
items: [],
selected: [],
group: ItemGroups.PEOPLE,
isLoading: false
});
const { items, group, selected, isLoading } = state;
useEffect(() => {
console.log("use effect on group change");
dispatch(fetchItems(group));
}, [group]);
React.useEffect(() => {
console.log("<App /> render");
});
return (
<div className="App">
<Controls {...{ group, dispatch }} />
{isLoading && <Loader />}
<List {...{ items, selected, dispatch }} />
</div>
);
}
Controls.js
import React, { memo } from "react";
import { ItemGroups } from "./constants";
import { setGroup, fetchItems } from "./actions";
const Controls = ({ dispatch, group }) => {
React.useEffect(() => {
console.log("<Controls /> render");
});
return (
<div className="Controls">
<label>
Select group
<select
value={group}
onChange={(e) => dispatch(setGroup(e.target.value))}
>
<option value={ItemGroups.PEOPLE}>{ItemGroups.PEOPLE}</option>
<option value={ItemGroups.TREES}>{ItemGroups.TREES}</option>
</select>
</label>
<button onClick={() => dispatch(fetchItems(group))}>Reload data</button>
</div>
);
};
List.js
import React, { memo } from "react";
import { deselectItem, selectItem } from "./actions";
const List = ({ dispatch, items, selected }) => {
React.useEffect(() => {
console.log("<List /> render");
});
return (
<ul className="List">
{items.map(({ id, name }) => (
<li key={`item-${name.toLowerCase()}`}>
<label>
<input
type="checkbox"
checked={selected.includes(id)}
onChange={(e) =>
dispatch((e.target.checked ? selectItem : deselectItem)(id))
}
/>
{name}
</label>
</li>
))}
</ul>
);
};
Loader.js
const Loader = () => {
React.useEffect(() => {
console.log("<Loader /> render");
});
return <div>Loading data...</div>;
};
I have a problem with React.
When I press the "+" button, this console message appears and nothing happens:
Cannot update during an existing state transition (such as within `render` or another component's constructor). Render methods should be a pure function of props and state; constructor side-effects are an anti-pattern, but can be moved to `componentWillMount`
I found several questions with similar titles, but common thing among them is that there were calls of functions with setState inside render method.
My render method has no calls, but error appears.
Why?
Thank you for reading.
Code:
import React from 'react';
const TodoForm = ({addTodo}) => {
let input;
return (
<div>
<input
ref={node => {
input = node;
}}
/>
<button onClick={() => {
addTodo(input.value);
input.value = '';
}}>
+
</button>
</div>
);
};
const Todo = ({todo, remove}) => {
// Each Todo
return (<li onClick={remove(todo.id)}>{todo.text}</li>)
};
const TodoList = ({todos, remove}) => {
// Map through the todos
const todoNode = todos.map((todo) => {
return (<Todo todo={todo} key={todo.id} remove={remove}/>)
});
return (<ul>{todoNode}</ul>);
};
const Title = () => {
return (
<div>
<div>
<h1>to-do</h1>
</div>
</div>
);
};
window.id = 0;
class TodoApp extends React.Component {
constructor(props) {
// Pass props to parent class
super(props);
// Set initial state
this.state = {
data: []
}
}
// Add todo handler
addTodo(val) {
// Assemble data
const todo = {text: val, id: window.id++}
// Update data
this.state.data.push(todo);
// Update state
console.log('setting state...');
this.setState({data: this.state.data});
}
// Handle remove
handleRemove(id) {
// Filter all todos except the one to be removed
const remainder = this.state.data.filter((todo) => {
if (todo.id !== id) return todo;
});
// Update state with filter
this.setState({data: remainder});
}
render() {
// Render JSX
return (
<div>
<Title />
<TodoForm addTodo={
(val)=>{
this.addTodo(val)
}
}/>
<TodoList
todos={this.state.data}
remove={this.handleRemove.bind(this)}
/>
</div>
);
}
}
export default TodoApp;
In your render method for Todo you invoke remove, which is where your erroneous state update happens.
To fix this, return a function from the handleRemove method of TodoApp that updates the state. Simplified version:
handleRemove(id) {
return () => {
...
this.setState({ data: remainder });
}
}
Also worth noting here that because you're using the current state, it's best to use the setState callback (which gets prevState as an argument), and not rely on this.state.
setState docs
Andy_D very helped and my answer has two solutions:
First in render function change
<TodoList
todos={this.state.data}
remove={this.handleRemove.bind(this)}
/>
to
<TodoList
todos={this.state.data}
remove={() => this.handleRemove.bind(this)}
/>
or change code
const Todo = ({todo, remove}) => {
// Each Todo
return (<li onClick={remove(todo.id)}>{todo.text}</li>)
};
to that:
const Todo = ({todo, remove}) => {
// Each Todo
return (<li onClick={() => remove(todo.id)}>{todo.text}</li>)
};