Scenario 1, forms with lots of fields:
I have this common problem where I have a form with lots of fields. At first I think an Atom Family fills this need. I'll set each atom in the family to one of the key value pairs of my form. But in order for that to work, I need to also have an atom that tracks all the keys in the form--otherwise how do I know what all my fields are? (There's no way to get all the values out of a family.) That's fine I guess, but it feels kinda clunky having two pieces of state that represent the same thing and need to be kept in sync. The reason I want to use an atom family in the first place is that when I update one value in my form, I don't want _all_ the form elements to update--only the one that updated should re-render. I also want there to be an easy way to get the form back as a single object so I've tried using selectors to combine the atom that has all the keys with the atom family, but I have to be careful not to use that selector higher up in the component hierarchy or else the entire tree will re-render when the selector reevaluates when any of the atoms update.
Question: What's the best way to manage the keys in the form? Or just, what's the best way to manage a form in general? Is having a companion atom with an array of its keys the best solution?
Scenario 2, asynchronously adding/removing from a family:
Another (similar) problem I have is a game where players can join a lobby. I want to store all the player objects in an atom family, keyed by their id. I thought atom effects would be perfect and I could subscribe each atom to websocket updates for its own player object, but the problem I've run into is... how do _new_ atoms get added to the family? I receive a websocket message saying a player joined, but I can't do a useRecoilState(playerState(newPlayer.id)) inside of that handler for the new atom because of the rules of hooks. And I don't believe atom effects will solve my problem because (as far as I know) you can't use them to add members to a family. How am I supposed to do this?
One (very bad) idea I had was to store the new player data from the websocket handler into local component state, then make a child component that accepts the new player object as a prop and then accesses its own atom from the family with a hook, but that feels super jank. I'd have something like:
function ComponentWhoseOnlyPurposeIsCreatingAnAtom({newPlayer}) {
const [player, setPlayer] = useRecoilState(playerFamily(newPlayer.id));
useEffect(() => {
setPlayer(newPlayer);
}, [newPlayer]);
return null; // don't render anything?
}
Then at that point, I have a similar solution to the form example where I have an atom that stores all the player IDs (the keys for its counterpart family of players), and I just add the new ID to that. This solution feels ridiculous and hacky, but I do think it would probably work eventually.
Question: How do I add new members to a family one at a time? Feels weird to have to put something in local state first, so it can go into a prop, so it can be used with a hook. In the form example I at least know all the fields I'll have ahead of time. What about when it's async?
I have a feeling that my trouble has to do with me not "thinking in Recoil" or something. Apologies if this is confusing.
I stumbled upon this a few days ago, that might be interesting for scenario 1: https://github.com/serverscom/recoil-form
@BenjaBobs I'll check that out!
Another question for the form--after I've submitted the form, all the values stay in the atom family, so I need each field component to reset its own atom when it unmounts (or when submit happens), and again, that feels clunky. Otherwise when the user tries to fill out another form, all the values from the previous form are filled in already.
Yes, for Scenario 1 it is a common pattern to use an atomFamliy() for things like field entries in a form alongside a list of field names. That list may be static or could also be dynamic state based on your usage model. This allows for limiting re-renders only for components that subscribe to specific changing fields. As you outline, you can use a selector to merge/set the individual fields, but it will subscribe to all changes then.
For your follow-up question one option could be to explicitly reset the atoms when unmounting the component. Another option could be to add another family parameter with whatever uniquely identifies the form state. So, for example, if you have form state per player in a game the key might be {field: string, playerID: number}.
For Scenario 2 it is true that you can't use an atom effect to create a new atom. That can be done by subscribing to websockets and then creating the atom. While the new atoms should be created relative to a React context, you don't need to make a component that is specific to the new player. For example, you could create a callback for your websocket subscription with useRecoilCallback() that can set, and thus create, the new atoms. That still requires some component to manage that subscription, though. If you want to avoid that entirely, here's a potential pattern: you could have a single atom that manages the list of current players that uses an atom effect to subscribe to the websocket for new players and updates itself with the current player list and then save the initial player data to be then used by an effect in the atom family of player data to initialize itself. (or leverage #707 when available)
Thanks for the quick response! I'll try this out.
useRecoilCallback is indeed very useful! It also kind of provides a way to access other recoil state via snapshots as desired in #707, albeit not directly in an atom effect, but it seems like a workaround for now. I created a setter that looks something like this
const setState = useRecoilCallback(
({ set, snapshot }) => async (state: GameState) => {
const playerNames = await snapshot.getPromise(playerNamesState);
const newNames = state.players.map(player => {
if (!playerNames.includes(player.name)) {
// only add players that aren't already in the family
// existing players subscribe to their own updates
set(playersState(player.name), player);
}
return player.name;
});
if (newNames.length !== playerNames?.length) {
set(playerNamesState, newNames);
}
},
[]
});
Side note: I'm curious why snapshots only allow you to look at Loadable or Promise wrapped state. I know my atom I'm accessing is synchronous, so the await snapshot.getPromise doesn't have to actually await anything.
Yes, for Scenario 1 it is a common pattern to use an
atomFamliy()for things like field entries in a form alongside a list of field names. That list may be static or could also be dynamic state based on your usage model. This allows for limiting re-renders only for components that subscribe to specific changing fields. As you outline, you can use a selector to merge/set the individual fields, but it will subscribe to all changes then.For your follow-up question one option could be to explicitly reset the atoms when unmounting the component. Another option could be to add another family parameter with whatever uniquely identifies the form state. So, for example, if you have form state per player in a game the key might be
{field: string, playerID: number}.For Scenario 2 it is true that you can't use an atom effect to create a new atom. That can be done by subscribing to websockets and then creating the atom. While the new atoms should be created relative to a React context, you don't need to make a component that is specific to the new player. For example, you could create a callback for your websocket subscription with
useRecoilCallback()that can set, and thus create, the new atoms. That still requires some component to manage that subscription, though. If you want to avoid that entirely, here's a potential pattern: you could have a single atom that manages the list of current players that uses an atom effect to subscribe to the websocket for new players and updates itself with the current player list and then save the initial player data to be then used by an effect in the atom family of player data to initialize itself. (or leverage #707 when available)
What if I have a lof of players and is it still okay for me to manage a list of current players with just a single atom? Will there be any significant performance loss if the number of players is large?
Side note: I'm curious why snapshots only allow you to look at Loadable or Promise wrapped state. I know my atom I'm accessing is synchronous, so the await snapshot.getPromise doesn't have to actually await anything.
Atoms and Selectors share the same interface and either may be asynchronous. You can't guarantee when getting the value of an atom or selector if it will have a value, be in an error state, or pending. getPromise() and getLoadable() help handle these states. You can use a Loadable convenience accessor like getValue() or valueOrThrow()if you want to make that assumption. e.g. snapshot.getLoadable(myAtom).getValue()
What if I have a lof of players and is it still okay for me to manage a list of current players with just a single atom? Will there be any significant performance loss if the number of players is large?
You can store large structures in atoms. One significant factor when deciding the granularity of atoms is the granularity you want for subscriptions. If the actual user data is stored in a separate atom family, then changes in that would impact components for other users. If the set of users doesn't change much, then it shouldn't cause too many re-renders. But, if it does change a lot and a lot of components use that atom, then that could be a concern.
Most helpful comment
What if I have a lof of players and is it still okay for me to manage a list of current players with just a single atom? Will there be any significant performance loss if the number of players is large?