使用 React useState() hook 更新和合并状态对象

我发现这两部分的反应钩文件有点混乱。哪一个是使用状态钩子更新状态对象的最佳实践?

假设想要进行以下状态更新:

INITIAL_STATE = {
propA: true,
propB: true
}


stateAfter = {
propA: true,
propB: false   // Changing this property
}

选择一

使用反应钩的文章中,我们得知这是可能的:

const [count, setCount] = useState(0);
setCount(count + 1);

所以我可以这样做:

const [myState, setMyState] = useState(INITIAL_STATE);

然后:

setMyState({
...myState,
propB: false
});

选择二

钩子参考文献我们可以看到:

与在类组件中找到的 setState 方法不同,useState 可以 不会自动合并更新对象 通过将函数更新器形式与对象扩展相结合来实现 句法:

setState(prevState => {
// Object.assign would also work
return {...prevState, ...updatedValues};
});

据我所知,两者都有效。那有什么区别呢?哪一个是最好的练习?我应该使用传递函数(OPTION 2)来访问以前的状态,还是应该使用扩展语法(OPTION 1)来访问当前的状态?

171123 次浏览

Both options are valid, but just as with setState in a class component you need to be careful when updating state derived from something that already is in state.

If you e.g. update a count twice in a row, it will not work as expected if you don't use the function version of updating the state.

const { useState } = React;


function App() {
const [count, setCount] = useState(0);


function brokenIncrement() {
setCount(count + 1);
setCount(count + 1);
}


function increment() {
setCount(count => count + 1);
setCount(count => count + 1);
}


return (
<div>
<div>{count}</div>
<button onClick={brokenIncrement}>Broken increment</button>
<button onClick={increment}>Increment</button>
</div>
);
}


ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>


<div id="root"></div>

The best practice is to use separate calls:

const [a, setA] = useState(true);
const [b, setB] = useState(true);

Option 1 might lead to more bugs because such code often end up inside a closure which has an outdated value of myState.

Option 2 should be used when the new state is based on the old one:

setCount(count => count + 1);

For complex state structure consider using useReducer

For complex structures that share some shape and logic you can create a custom hook:

function useField(defaultValue) {
const [value, setValue] = useState(defaultValue);
const [dirty, setDirty] = useState(false);
const [touched, setTouched] = useState(false);


function handleChange(e) {
setValue(e.target.value);
setTouched(true);
}


return {
value, setValue,
dirty, setDirty,
touched, setTouched,
handleChange
}
}


function MyComponent() {
const username = useField('some username');
const email = useField('some@mail.com');


return <input name="username" value={username.value} onChange={username.handleChange}/>;
}

Both are perfectly fine for that use case. The functional argument that you pass to setState is only really useful when you want to conditionally set the state by diffing the previous state (I mean you can just do it with logic surrounding the call to setState but I think it looks cleaner in the function) or if you set state in a closure that doesn't have immediate access to the freshest version of previous state.

An example being something like an event listener that is only bound once (for whatever reason) on mount to the window. E.g.

useEffect(function() {
window.addEventListener("click", handleClick)
}, [])


function handleClick() {
setState(prevState => ({...prevState, new: true }))
}

If handleClick was only setting the state using option 1, it would look like setState({...prevState, new: true }). However, this would likely introduce a bug because prevState would only capture the state on initial render and not from any updates. The function argument passed to setState would always have access to the most recent iteration of your state.

One or more options regarding state type can be suitable depending on your usecase

Generally you could follow the following rules to decide the sort of state that you want

First: Are the individual states related

If the individual state that you have in your application are related to one other then you can choose to group them together in an object. Else its better to keep them separate and use multiple useState so that when dealing with specific handlers you are only updating the relavant state property and are not concerned about the others

For instance, user properties such as name, email are related and you can group them together Whereas for maintaining multiple counters you can make use of multiple useState hooks

Second: Is the logic to update state complex and depends on the handler or user interaction

In the above case its better to make use of useReducer for state definition. Such kind of scenario is very common when you are trying to create for example and todo app where you want to update, create and delete elements on different interactions

Should I use pass the function (OPTION 2) to access the previous state, or should I simply access the current state with spread syntax (OPTION 1)?

state updates using hooks are also batched and hence whenever you want to update state based on previous one its better to use the callback pattern.

The callback pattern to update state also comes in handy when the setter doesn't receive updated value from enclosed closure due to it being defined only once. An example of such as case if the useEffect being called only on initial render when adds a listener that updates state on an event.

Which one is the best practice for updating a state object using the state hook?

They are both valid as other answers have pointed out.

what is the difference?

It seems like the confusion is due to "Unlike the setState method found in class components, useState does not automatically merge update objects", especially the "merge" part.

Let's compare this.setState & useState

class SetStateApp extends React.Component {
state = {
propA: true,
propB: true
};


toggle = e => {
const { name } = e.target;
this.setState(
prevState => ({
[name]: !prevState[name]
}),
() => console.log(`this.state`, this.state)
);
};
...
}


function HooksApp() {
const INITIAL_STATE = { propA: true, propB: true };
const [myState, setMyState] = React.useState(INITIAL_STATE);


const { propA, propB } = myState;


function toggle(e) {
const { name } = e.target;
setMyState({ [name]: !myState[name] });
}
...
}


Both of them toggles propA/B in toggle handler. And they both update just one prop passed as e.target.name.

Check out the difference it makes when you update just one property in setMyState.

Following demo shows that clicking on propA throws an error(which occurs setMyState only),

You can following along

Edit nrrjqj30wp

Warning: A component is changing a controlled input of type checkbox to be uncontrolled. Input elements should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component.

error demo

It's because when you click on propA checkbox, propB value is dropped and only propA value is toggled thus making propB's checked value as undefined making the checkbox uncontrolled.

And the this.setState updates only one property at a time but it merges other property thus the checkboxes stay controlled.


I dug thru the source code and the behavior is due to useState calling useReducer

Internally, useState calls useReducer, which returns whatever state a reducer returns.

https://github.com/facebook/react/blob/2b93d686e3/packages/react-reconciler/src/ReactFiberHooks.js#L1230

    useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
currentHookNameInDev = 'useState';
...
try {
return updateState(initialState);
} finally {
...
}
},

where updateState is the internal implementation for useReducer.

function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}


useReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
currentHookNameInDev = 'useReducer';
updateHookTypesDev();
const prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
try {
return updateReducer(reducer, initialArg, init);
} finally {
ReactCurrentDispatcher.current = prevDispatcher;
}
},

If you are familiar with Redux, you normally return a new object by spreading over previous state as you did in option 1.

setMyState({
...myState,
propB: false
});

So if you set just one property, other properties are not merged.

Both options are valid but they do make a difference. Use Option 1 (setCount(count + 1)) if

  1. Property doesn't matter visually when it updates browser
  2. Sacrifice refresh rate for performance
  3. Updating input state based on event (ie event.target.value); if you use Option 2, it will set event to null due to performance reasons unless you have event.persist() - Refer to event pooling.

Use Option 2 (setCount(c => c + 1)) if

  1. Property does matter when it updates on the browser
  2. Sacrifice performance for better refresh rate

I noticed this issue when some Alerts with autoclose feature that should close sequentially closed in batches.

Note: I don't have stats proving the difference in performance but its based on a React conference on React 16 performance optimizations.

If anyone is searching for useState() hooks update for object

  • Through Input

    const [state, setState] = useState({ fName: "", lName: "" });
    const handleChange = e => {
    const { name, value } = e.target;
    setState(prevState => ({
    ...prevState,
    [name]: value
    }));
    };
    
    
    <input
    value={state.fName}
    type="text"
    onChange={handleChange}
    name="fName"
    />
    <input
    value={state.lName}
    type="text"
    onChange={handleChange}
    name="lName"
    />
    
  • Through onSubmit or button click

        setState(prevState => ({
    ...prevState,
    fName: 'your updated value here'
    }));
    

The solution I am going to propose is much simpler and easier to not mess up than the ones above, and has the same usage as the useState API.

Use the npm package use-merge-state (here). Add it to your dependencies, then, use it like:

const useMergeState = require("use-merge-state") // Import
const [state, setState] = useMergeState(initial_state, {merge: true}) // Declare
setState(new_state) // Just like you set a new state with 'useState'

Hope this helps everyone. :)

I find it very convenient to use useReducer hook for managing complex state, instead of useState. You initialize state and updating function like this:

const initialState = { name: "Bob", occupation: "builder" };
const [state, updateState] = useReducer(
(state, updates) => {...state, ...updates},
initialState
);

And then you're able to update your state by only passing partial updates:

updateState({ occupation: "postman" })