Skip to content

Instantly share code, notes, and snippets.

@KidkArolis
Last active November 17, 2020 11:21
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save KidkArolis/81cd94f0bb24f1239f2a434958c2c8c0 to your computer and use it in GitHub Desktop.
Save KidkArolis/81cd94f0bb24f1239f2a434958c2c8c0 to your computer and use it in GitHub Desktop.
Designing custom React hooks - sequencing of async effects

We've been building a hooks based REST API wrapper.

A specific use case discussed in this gist is – how do you close a modal after updating a remote resource completes succesfully?

Which of the following options seems best or is there a better approach altogether?

1. Async/Await

function EditNoteModal ({ id, onClose }) {
  const { patch } = useResource('api/notes')
  const [formError, setFormError] = useState()

  async function onSubmit () {
    try {
      await patch(id, { note: 'bla' })
      onClose()
    } catch (err) {
      setFormError(err)
    }
  }

  return (
    <Modal>
      <Form onSubmit={onSubmit} error={formError}>
    </Modal>
  )
}

This is not great, because if the modal is unrendered while request is in flight, you're setting state on unmounted component or calling onClose when it's already closed. Async/await takes away the control from the hooks/React in an uncontrollable/uninteruptable way.

2. Declarative approach

function EditNoteModal ({ id, onClose }) {
  const { error, patching, patched, patch } = usePatch('api/notes')

  function onSubmit () {
    patch(id, { note: 'bla' })
  }

  useEffect(() => {
    if (patched) {
      onClose()
    }
  }, [patched])

  return (
    <Modal>
      <Form onSubmit={onSubmit} error={error}>
    </Modal>
  )
}

This doesn't have the shortcomings of the previous approach. If modal is closed mid flight, the effect won't be executed and modal will not be closed twice. Also error handling is easier. I'm not sure this handles multiple patches well (say if you're interacting with a list that allows user to modify multiple resources easily).

3. Effectful API

function EditNoteModal ({ id, onClose }) {
  const { patch } = useResource('api/notes')
  const [formError, setFormError] = useState()

  function onSubmit () {
    patch(id, { note: 'bla' }, (err) => {
      // this is an effect!
      // it's not gonna be called if
      // this component is unmounted
      if (err) setFormError(err)
      else onClose()
    })
  }

  return (
    <Modal>
      <Form onSubmit={onSubmit} error={formError}>
    </Modal>
  )
}

This is quite cool. The only odd thing is error handling reminiscent of Node's callbacks. But that seems to be the way effects in React currently work. I think this handles lists of items better than the previous approach.

4. Effectful API with in built error handling

function EditNoteModal ({ id, onClose }) {
  // errors is an array of errors that happened as part of the
  // operations that were performed, e.g. [{ type: 'patch', id, error }]
  const { patch, errors } = useResource('api/notes')

  function onSubmit () {
    patch(id, { note: 'bla' }, (err) => {
      // this is an effect!
      // it's not gonna be called if
      // this component is unmounted
      if (!err) onClose()
    })
  }

  return (
    <Modal>
      <Form onSubmit={onSubmit} error={errors && errors[0].error}>
    </Modal>
  )
}

This is similar, but errors are being populated by the original hook in a way that supports many operations, e.g. if you're patching 2 items and removing 1 (say all triggered as part of the user interacting with a list), all those errors would get populated into the errors array.

5. Use tiny-atom's async effects/actions

import { useAtom } from 'tiny-atom'

function MyModal ({ id, onClose }) {
  const notes = useResource('api/notes')

  const [state, actions] = useAtom(() => ({
    actions: {
      close () {
        onClose()
      },
      async patch({ set, actions }) {
        try {
          await notes.patch(id, { note: 'bla' })
          // this will not be dispatched if component
          // is already unmounted, i.e. further effects
          // are cancelled
          actions.close()
        } catch (error) {
          // this will update atom, but will not
          // trigger a rerender if component is
          // already unmounted
          set({ error })
        }
      }
    }
  }))

  function onSubmit () {
    actions.patch(id, { note: 'bla' })
  }

  return (
    <Modal>
      <Form onSubmit={onSubmit} error={state.error}>
    </Modal>
  )
}

This is a more verbose approach compared to the previous ones, but it's the most flexible. You get to control how your state looks like, how errors are handled, what happens when you perform multiple operations, etc. And it allows using async/await without the drawbacks of the first approach, since useAtom hook will handle components unmounting for you. (Note: this API is experimental and not released yet).

Questions

  • Does Suspense help in this scenario or is Suspense only useful when reading data?
  • How does Apollo useMutation handle this case? I.e. how do you close the modal there after mutation succeeds?
  • Is this "Dancing between state and effects" facebook/react#15240?
  • Do you know any good and simple hooks based REST wrappers?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment