Writing Mutations With Custom Hooks
There are numerous ways of dealing with mutations be it directly inside your event handlers, as a side effect of your state transitions with something like redux-saga or xstate async sequences or a hook based similar to react-queries useMutation
primitive. However, I’d like to propose a different approach, using what react thought us about declarative and the tools it gave for build our own tools.
The problem
For most things, the current solutions are too much, and you can get by with way less code and much lower complexity. And why would that be desirable? Code quantity doesn’t correlate with quality. Yes, that is true, but it ignores the fact that without using external libraries you won’t need to rely on third party’s abstractions and all the logic related to a code branch is just a few F12
s away. Also, not having another dependency means developers don’t need to learn them to contribute, lowering the knowledge barrier for interacting with the code base.
The case against complexity is a bit trickier, as there’s no rigorous definition for it but as a grug developer once said complexity very, very bad. What I’m referring to as complexity here means roughly how many things I need to care about when making a change on X? the lower the number of things the better. And, in my experience, what helps with trapping complexity demon are two things, type-safety and reducing indirections. Both of those get harder to deal with the more layers you add between your trigger, an event handler or state change, and the actual logic you want to run.
Although I’m criticizing them here, clearly the problem those solutions face isn’t easy, querying and managing state, and I think they got too caught up in trying to solve adjacent problems, data mutation. But I want to propose a simpler solution, what if we could handle async logic similarly to how we deal with the client-side state using useState
?
A different way
This is all pseudo-react code, it may not work, but the ideas are there
The demo of a counter button is a classic, and you’d write something like this using client-side state:
tsx
function Counter() {const [count, setCount] = useState(o)function increment() {setCount(count + 1)}function decrement() {setCount(count - 1)}return (<div><button onClick={increment}>increment</button>{count}<button onClick={decrement}>decrement</button></div>)}
tsx
function Counter() {const [count, setCount] = useState(o)function increment() {setCount(count + 1)}function decrement() {setCount(count - 1)}return (<div><button onClick={increment}>increment</button>{count}<button onClick={decrement}>decrement</button></div>)}
Now, what happens if the count is now stored in a database and you need to make a request every time? Surely in the start, you can get by just changing the implementation of increment
and decrement
and adding some sloppy logic to refetch the counter on change:
tsx
function AsyncCounter() {const [count, setCount] = useState(null)const [shouldRefetch, setShouldRefetch] = useState(false)useEffect(() => {if (shouldRefetch || !data) {(async () => {const data = await fetch(GET_COUNT_URL)setCount(data)setShouldRefetch(false)}) // If you didn't know this is called an IIFE!}},[shouldRefetch])async function increment() {fetch(POST_COUNT_INCREMENT, {method: "POST"})}async function decrement() {fetch(POST_COUNT_DECREMENT, {method: "POST"})}if (!count) {return <div>loading</div>return (<div><button onClick={increment}>increment</button>{count}<button onClick={decrement}>decrement</button></div>)}
tsx
function AsyncCounter() {const [count, setCount] = useState(null)const [shouldRefetch, setShouldRefetch] = useState(false)useEffect(() => {if (shouldRefetch || !data) {(async () => {const data = await fetch(GET_COUNT_URL)setCount(data)setShouldRefetch(false)}) // If you didn't know this is called an IIFE!}},[shouldRefetch])async function increment() {fetch(POST_COUNT_INCREMENT, {method: "POST"})}async function decrement() {fetch(POST_COUNT_DECREMENT, {method: "POST"})}if (!count) {return <div>loading</div>return (<div><button onClick={increment}>increment</button>{count}<button onClick={decrement}>decrement</button></div>)}
But this solution has some problems, the worse ones are, (1) lacks error handling, (2) no loading indicators during mutations, (3) missing types for the API calls. And yes, we could add them, but this is the point where you’d probably start looking for some libs that could help you in the way, if not your component would become huge and coupled with the async logic. The approach I’m proposing you look something like this:
tsx
function AsyncCounter() {const {mutating, query, increment, decrement} = useAsyncCounter()if (!query.data) {return <div>loading</div>}return (<div><button onClick={increment}>increment</button>{query.data}<button onClick={decrement}>decrement</button></div>)}// lib/useAsyncCounter.tsfunction useAsyncCounter () {const [mutating, setMutating] = useState(false)const query = useQuery(["counter-value"], async () => {const data = await fetch<number>(GET_COUNT_URL)return data})async function increment() {setMutating(true)try {await fetch(POST_COUNT_INCREMENT, {method: "POST"})query.refetch()} catch {console.log("error incrementing")} finally {setMutating(false)}}async function decrement() {setMutating(true)try {fetch(POST_COUNT_DECREMENT, {method: "POST"})} catch {console.log("error decrementing")} finally {setMutating(false)}}return {mutating,query,increment,decrement}}
tsx
function AsyncCounter() {const {mutating, query, increment, decrement} = useAsyncCounter()if (!query.data) {return <div>loading</div>}return (<div><button onClick={increment}>increment</button>{query.data}<button onClick={decrement}>decrement</button></div>)}// lib/useAsyncCounter.tsfunction useAsyncCounter () {const [mutating, setMutating] = useState(false)const query = useQuery(["counter-value"], async () => {const data = await fetch<number>(GET_COUNT_URL)return data})async function increment() {setMutating(true)try {await fetch(POST_COUNT_INCREMENT, {method: "POST"})query.refetch()} catch {console.log("error incrementing")} finally {setMutating(false)}}async function decrement() {setMutating(true)try {fetch(POST_COUNT_DECREMENT, {method: "POST"})} catch {console.log("error decrementing")} finally {setMutating(false)}}return {mutating,query,increment,decrement}}
This uses a react-query-like solution to query the value, and deal with refetch and loading but everything else is just your logic with some more edge-case handling and that’s the beauty of it. That said, I’m sure this isn’t a silver bullet solution and the case for the tools mentioned above does exist, but this proposes a middle ground where you can have much better developer experience and write a third of the code.