Generally speaking, you ought to avoid starting side effects within a reducer. That's why reducers are normally free of async functions, and ought to stay like that.
When an action changes reducer state, you can make that state a dependency for something like useEffect, and perform your (async) side effects there, including fetch calls.
Listing your component's possible states explicitly can also help:
type model = Pending | Ready(Profile.t)
type event = ReplaceState(model)
let reduceState = (model, event) =>
switch event {
| ReplaceState(model) => model
}
@react.component
let make = () => {
let (model, sendAction) = React.useReducer(reduceState, Pending)
React.useEffect(() => {
fetch("")
->Promise.then(Response.json)
->Promise.thenResolve(payload => {
let record = JSON.Decode.object(payload)->Option.getOrThrow
let profile = Profile.fromJson(record)
sendAction(ReplaceState(Ready(profile)))
})
->Promise.ignore
None
}, [sendAction]
switch model {
| Ready(profile) => <div> {React.string("Profile ready")} </div>
| Pending => <div> {React.string("Waiting...")} </div>
}
}
Having said that, I frequently reach for a trick that forces an async context whenever needed:
module AsyncRunner = {
let run = (createTask: unit => promise<unit>) =>
createTask()->Promise.ignore
}
let handleSync = () =>
AsyncRunner.run(async () => {
let payload = await fetch("")->Promise.then(Response.json)
let record = JSON.Decode.object(payload)->Option.getOrThrow
let profile = Profile.fromJson(record)
sendAction(ReplaceState(Ready(profile)))
})