Recoil doesn't have a useSubscription in the API. Could you clarify your question?
For instance how do I put below logic inside a selector instead of using useEffect and useState as I have to cleanup(ChatAPI.unsubscribeFromFriendStatus) on unmount/friend.id changes.
Or should I just use useEffect and useState only for this purpose?
import React, { useState, useEffect } from 'react';
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
Hi @sinupoonia. Side-effects, like subscribing to a ChatAPI should still be done inside useEffect.
You can use either useState or useRecoilState depending on your use case.
Hi @acutmore , But as mentioned here https://recoiljs.org/docs/guides/asynchronous-data-queries fetch requests are made in selectors. Why not use same for subscriptions
Hi @sinupoonia. Good question. The Recoil team will likely have more details though I will try and answer.
There are two issues with starting a subscription inside a selector. One is that there is no cancelation of async selectors (#51) so the subscription would never be torn down. The second is that a selector can only return one value, either synchronously or wrapped as a promise.
A selector represents a "pure" function or transformation. It can be used to abstract a query, even a query with parameters, but the query should always return the same results. Internally selectors are cached and evaluation functions may be executed one or more times, so side effects are problematic. We're thinking about cancelation, that may hopefully fit into the memory management control / reaping.
That said, I wonder if an atom may be better for your situation. The atom could represent the local state for your remote server status. You could subscribe to the Chat API with a normal effect and set the atom state from there based on the subscription. The advantage of using an atom vs just React state here would be that you could then use the atom state to feed as input to other selectors for derived state in your data flow graph, &c.
To allow two-way communication you could subscribe to observing atom changes with useRecoilState and then use an effect to send a post. With some logic to avoid feedback loops, of course.. All of this could be abstracted in your own hook. @sinupoonia , if that works for you lets work on some example documentation as this seems like a recurring request. I'm thinking something like:
const friendStatusState = atomFamily({
key: 'FriendStatus',
default: null,
});
function useFriendStatus({id}: Friend): Status {
const [status, setStatus] = useRecoilState(friendStatusState(id));
const knownServerStatus: Status;
// Subscribe Atom state to match server
useEffect(() => {
function handleStatusChange(status) {
knownServerStatus = status;
setStatus(status);
}
ChatAPI.subscribeToFriendStatus(id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange);
};
}, []);
// Subscribe server to match atom state
useEffect(() => {
if (status !== knownServerStatus) {
knownServerStatus = status;
ChatAPI.changeStatus(status);
}
}, [status, knownServerStatus]);
return status;
}
function FriendStatus({friend}: {friend: Friend}) {
const status = useFriendStatus(friend);
return status == null ? 'Loading...' : isOnline ? 'Online' : 'Offline';
}
Personally I've found in these situations it's less error prone to only keep one source of truth. Similar to database replication, having one elected leader instead of multi-leader is usually easier to reason about.
If it's okay to have impure setters could do something like this:
const friendStatusTrigger = atomFamily({
key: 'FriendStatusTrigger',
default: 0,
});
const friendStatus = selectorFamily ({
key: 'FriendStatus',
get: (id) => async ({get}) => {
get(friendStatusTrigger);
return await ChatAPI.getFriendStatus(id);
},
set: (id) => async ({set}, value) => {
await ChatAPI.updateFriendStatus(id, value);
set(friendStatusTrigger, v => v + 1);
}
});
function FriendStatusSubscription({id}) {
const trigger = useSetRecoilState(friendStatusTrigger(id));
useEffect(() => {
function handleStatusChange() {
trigger(v = v + 1);
}
ChatAPI.subscribeToFriendStatus(id, handleStatusChange);
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange);
};
}, []);
return null;
}
Yeah, we def want a single source of truth. And yeah, a component prob looks cleaner for that.
const friendStatusState = atomFamily({
key: 'FriendStatus',
default: null,
});
function FriendStatusSubscription({friend}) {
const [status, setStatus] = useRecoilState(friendStatusState(friend.id));
const knownServerStatus: Status;
// Subscribe Atom state to match server
useEffect(() => {
function handleStatusChange(status) {
knownServerStatus = status;
setStatus(status);
}
ChatAPI.subscribeToFriendStatus(id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(friend.id, handleStatusChange);
};
}, []);
// Subscribe server to match atom state
useEffect(() => {
if (status !== knownServerStatus) {
knownServerStatus = status;
ChatAPI.updateFriendState(friend.id, status);
}
}, [friend.id, status, knownServerStatus]);
return null;
}
function FriendStatus({friend}: {friend: Friend}) {
const status = useRecoilValue(friendStatusState(friend.id));
return status == null ? 'Loading...' : isOnline ? 'Online' : 'Offline';
}
function MyApp() {
return (
<RecoilRoot>
<FriendStatusSubscription friend={myFriend} />
<FriendStatus friend={myFriend} />
</RecoilRoot>
);
}
@drarmstr Awesome.Thanks! Though I have one concern.
Why can't we still stick with hook instead of component to achieve single source of truth?
const friendStatusState = atomFamily({
key: 'FriendStatus',
default: null,
});
function useFriendStatusSubscription({friend}) {
const [status, setStatus] = useRecoilState(friendStatusState(friend.id));
let knownServerStatus: Status;
// Subscribe Atom state to match server
useEffect(() => {
function handleStatusChange(status) {
knownServerStatus = status;
setStatus(status);
}
ChatAPI.subscribeToFriendStatus(id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(friend.id, handleStatusChange);
};
}, []);
// Subscribe server to match atom state
useEffect(() => {
if (status !== knownServerStatus) {
knownServerStatus = status;
ChatAPI.updateFriendState(friend.id, status);
}
}, [friend.id, status, knownServerStatus]);
return null;
}
function FriendStatus({friend}: {friend: Friend}) {
const status = useRecoilValue(friendStatusState(friend.id));
return status == null ? 'Loading...' : isOnline ? 'Online' : 'Offline';
}
function MyApp(myFriend) {
useFriendStatusSubscription(myFriend);
return (
<FriendStatus friend={myFriend} />
);
}
An alternative would be to use another AtomFamily to track how many useFriendStatus hooks are currently 'active' to ensure only one of them is subscribed at any one time.
const friendStatusSubscription = atomFamily({
key: "FriendStatusSubscription",
default: [],
});
const friendStatusState = atomFamily({
key: "FriendStatus",
default: null,
});
function useFriendStatus({ friend }) {
const [subs, setSubs] = useRecoilState(friendStatusSubscription(friend.id));
const [status, setStatus] = useRecoilState(friendStatusState(friend.id));
const ID = useRef({}).current;
// Subscribe only if our ID is at the head of the array
const subscribe = subs[0] === ID;
// Add ID to subscription set
useEffect(() => {
setSubs((subs) => subs.concat(ID));
return () => {
setSubs((subs) => remove(subs, ID));
};
}, [ID]);
// Subscribe Atom state to match server
useEffect(() => {
if (!subscribe) {
return;
}
ChatAPI.subscribeToFriendStatus(id, setStatus);
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(friend.id, setStatus);
};
}, [subscribe, setStatus]);
return status;
}
@sinupoonia - Yup, either a hook or a component, whichever makes more sense for your application.
@acutmore - It may be convenient for some usage to abstract the subscription in the hook that returns the value. However, something to keep in mind is that if the point of syncing the state with atoms is to use them as dependencies for other selectors and derived state, then there could be a problem if selectors depend on the atoms and no components happen to be using hooks for that friend. Just something to keep in mind. Different patterns may make sense for different use-cases.
I tried to clarify the async data query docs and added a new guide for syncing state if you have more feedback with #159
Hi @drarmstr
However, something to keep in mind is that if the point of syncing the state with atoms is to use them as dependencies for other selectors and derived state, then there could be a problem if selectors depend on the atoms and no components happen to be using hooks for that friend.
Very very good point. I'll take a look at #159 see if can help leave any feedback.
Most helpful comment
Yeah, we def want a single source of truth. And yeah, a component prob looks cleaner for that.