Hey @ctrlplusb, congrats on the library! I love this architecture and it seems like you've done a really nice job on the API.
I've been trying to build a model that fetches some data. I couldn't see any examples in the docs on how to handle errors (I had a look at onThunk but it didn't feel right, especially as it doesn't prevent exceptions being throw) so I've set up something like this:
const model = {
state: 'pending',
error: null,
data: null,
setState: action(...),
setError: action(...),
setData: action(...),
fetchData: thunk(actions => {
actions.setStatus('loading');
try {
const data = await api.getData();
actions.setStatus('loaded');
actions.setData(data);
} catch(err) {
actions.setStatus('error');
actions.setError(err);
}
})
}
Pretty similar to what you would do with useState and useEffect.
Where I'm struggling now is how to work that thunk into a component. I'd like to be able to cancel the request if the component unmounts. Here's a couple of examples where the solution is not obvious to me:
const DataView = () => {
const { data } = useStoreState(state => state.model);
const { fetchData } = useStoreActions(actions => actions.model);
// fetch data on mount
useEffect(() => {
// can't pass a cleanup function to useEffect as the return value is wrapped in a promise (useEffect callback has to be syncronous)
fetchData();
}, [fetchData]);
return <pre>{JSON.stringify(data)}</pre>;
}
const DataUpdater = () => {
const { updateData } = useStoreActions(actions => actions.model);
return <button onClick={() => updateData(newData)}>Update</button>
};
Am I looking at this in completely the wrong way?! Appreciate your thoughts!
Hey @djgrant! Wow, great to hear that you are finding this library useful, and thanks for the kind words. 馃榾
For sure what you want is technically _possible_, however, this is not a feature that is native to Easy Peasy.
Something critical to be aware of in terms of thunks: their return value is received by the caller.
For example, given the following thunk:
myThunk: thunk(() => {
return 'hello';
});
A caller could interact with the return value:
const result = myThunk();
console.log(result); // 'hello'
In the case that you are utilising async/await a Promise will be returned.
myThunk: thunk(async () => {
return 'hello';
});
The caller would have to await on the returned promise:
const result = await myThunk();
console.log(result); // 'hello'
Utilising these ideas it would be possible for you to introduce some sort of mechanism such as an AbortController and then return out of your thunk an API by which the caller could utilise to cancel the thunk execution.
Here is a complete random riff of an idea which I haven't tested:
fetchData: thunk((actions) => {
// create an abort controller instance
const controller = new AbortController();
// encapsulate our async work internally to make it easier
// for us to attach abort functionality
const doSomething = async () => {
actions.setStatus('loading');
try {
// ensure that we extend our API to accept abort controller signal
const data = await api.getData({ abortSignal: controller.signal });
actions.setStatus('loaded');
actions.setData(data);
} catch(err) {
if (err instanceof AbortError) {
// this was aborted - do nothing
} else {
actions.setStatus('error');
actions.setError(err);
}
}
};
const promise = doSomething();
// patch an abort method to our promise
Object.assign(promise, {
abort: () => {
controller.abort()
}
});
return promise;
})
Then the caller could handle this as so:
const DataView = () => {
const { data } = useStoreState(state => state.model);
const { fetchData } = useStoreActions(actions => actions.model);
// fetch data on mount
useEffect(() => {
// can't pass a cleanup function to useEffect as the return value is wrapped in a promise (useEffect callback has to be syncronous)
// call our thunk and get a handle on the returned promise
const fetchDataPromise = fetchData();
// return a dispose fn that calls the patched on abort fn
return () => fetchDataPromise.abort();
}, [fetchData]);
return <pre>{JSON.stringify(data)}</pre>;
}
It's a bit fidgety and boilerplatey in the above form, but perhaps you can create a util after you identify a nice API/abstraction for your specific use cases.
Note: It's important to see I didn't define the thunk as async. There are no awaits at the top level of the thunk definition. If I did define it as async the promise that we are returning within the thunk would be wrapped by another promise.
Hope this provides enough inspiration to unblock you. 馃憤
Also, just a note. Don't feel like you need to oversubscribe to the idea that all async data logic should be defined as thunks within your store. See what feels best for your app. It's okay to manage some data/api calls via your component life cycles and delegate to the store actions to update state where appropriate.
Note: It's important to see I didn't define the thunk as async. There are no awaits at the top level of the thunk definition. If I did define it as async the promise that we are returning within the thunk would be wrapped by another promise.
Actually, returning a promise in an async function will result in a promise chain. See Rewriting_a_promise_chain_with_an_async_function on MDN.
Ah sweet, bad assumption! TIL, thanks 猸愶笍
Ah, static method on the promise! Nice idea. I'll give that a go.
I did have a think about moving data fetching back into components. I think that makes sense if you're using a GraphQL library, for example, that handles it's own state logic. In my particular case I'm just calling REST endpoints so being semi indoctrinated in the mobx-state-tree ethos of building state machines that are separate from UI, I'm going for that!
Thanks for the really detailed reply!