9 March, 2019
We were discussing with @erusev what we can do with async operation when using useReducer()
in our application. Our app is simple and we don't want to use a state management library. All our requirements are satisfied with using one root useReducer()
. The problem we are facing and don't know how to solve is async operations.
In a discussion with Dan Abramov he recommends Solution 3 but points out that things are fresh with hooks and there could be better ways of handling the problem.
Doing asynchronous operations in a useReducer
reducer is not possible. We have thought of three possible solutions and can't figure which one is better or if there is an even better solution.
We are searching for a solution where a single action will be used multiple times in multiple places all over the application.
Just manually call the async function and after it completes call the dispatch.
- No additional abstraction
- Doesn't introduce additional learning curve because it uses already existing ideas
- Now calling
dispatch({ type: 'DELETE_FILE' })
have an invisible dependency/requirement. If you don't execute the required code before thedispatch
call you are calling for a strange bug that can be missed depending on the app architecture - For larger async operations we need to extract the code in a global place
function App() {
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'DELETE_FILE':
let index = state.files.indexOf(action.file);
return {
...state,
files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
};
}
}, {
files: ['a', 'b', 'c'],
});
return (
<DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
<Component files={state.files} />
</DispatchContext.Provider>
);
}
function Component({ files }) {
const dispatch = useContext(DispatchContext);
function deleteFile(file) {
unlink(file, () => {
dispatch({ type: 'DELETE_FILE', file: file });
});
}
return (
<>
{files.map(file =>
<button onClick={() => deleteFile(file)}>Delete File</button>
)}
</>
);
}
Use useEffect()
hook to delete the file.
- The only location where a side effect like writing to a file or fetching data can be is in a
useEffect()
hook. This improves the cognitive load of understanding the code.
- An additional state property
- State property which may not be used in the UI. Not sure if that is a problem. Maybe state properties that are not part of the UI are normal.
- If while deleting the file we don't want to show a UI indication the code goes through the component twice which is a small inefficiency and a confusion when you imagine it. Goes through the component logic just to execute a
useEffect()
call.
function App() {
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'REQUEST_DELETE_FILE':
return {
...state,
deleteFile: action.file
};
case 'DELETE_FILE':
const index = state.files.indexOf(action.file);
return {
...state,
files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
};
}
}, {
deleteFile: null,
files: ['a', 'b', 'c'],
});
useEffect(() => {
if (!state.deleteFile) {
return;
}
unlink(state.deleteFile, () => {
dispatch({ type: 'DELETE_FILE', file: state.deleteFile });
});
}, [state.deleteFile]);
return (
<DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
<Component files={state.files} />
</DispatchContext.Provider>
);
}
function Component({ files }) {
const dispatch = useContext(DispatchContext);
return (
<>
{files.map(file =>
<button onClick={dispatch({ type: 'REQUEST_DELETE_FILE', file: file })}>Delete File</button>
)}
</>
);
}
Use a middleware for dispatch which performs the async operation and then calls the actual dispatch.
- Doesn't have the disadvantages of Solution 1 and Solution 2
- A more complicated architecture. Two places where actions are handled.
function dispatchMiddleware(dispatch) {
return (action) => {
switch (action.type) {
case 'DELETE_FILE':
unlink(action.file, () => dispatch(action));
break;
default:
return dispatch(action);
}
};
}
function App() {
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'DELETE_FILE':
let index = state.files.indexOf(action.file);
return {
...state,
files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
};
}
}, {
files: ['a', 'b', 'c']
});
return (
<DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
<Component files={state.files} />
</DispatchContext.Provider>
);
}
function Component({ files }) {
const dispatch = useContext(DispatchContext);
return (
<>
{files.map(file =>
<button onClick={dispatch({ type: 'DELETE_FILE', file: file })}>Delete File</button>
)}
</>
);
}
While discussing we raised two questions we are not sure the answers to. They can help us in deciding the right pattern:
- Is it normal to do side effects like writing a file or fetching data in an event handler or it should be only in
useEffect()
? - Is it normal to have properties in the state object which are not used in the view at all?
Hey,
I've been playing with React for a week now, to replace the existing UI of CommaFeed (https://github.com/Athou/commafeed), currently written in angularjs 1.x. I encounterd the same issues as described here, and I've come to the same conclusions as you.
I think the issue rise from the fact that there's nothing similar to redux-thunk in React hooks.
I've started experimenting with https://github.com/nathanbuchar/react-hook-thunk-reducer and the result looks quite clean to me, but I don't have a lot of experience with React yet. Do you mind giving your opinion on https://github.com/Athou/commafeed-ui/blob/1821601b56ef4fbc6effc069709279371e03bc99/src/app/AppReducer.ts ?
The idea is that my action creator can create both plain old state changing actions and function actions (thunk), and the custom dispatch can handle both. From the perspective of the components dispatching the action, there's no difference, it always looks like
dispatch(ActionCreator.createSomeAction())
What do you think?
Thanks!