Dynamic atom keys in Recoil
Asked Answered
S

2

3

I'm trying to make a dynamic form where the form input fields is rendered from data returned by an API.

Since atom needs to have a unique key, I tried wrapping it inside a function, but every time I update the field value or the component re-mounts (try changing tabs), I get a warning saying:

""

I made a small running example here https://codesandbox.io/s/zealous-night-e0h4jt?file=/src/App.tsx (same code as below):

import React, { useEffect, useState } from "react";
import { atom, RecoilRoot, useRecoilState } from "recoil";
import "./styles.css";

const textState = (key: string, defaultValue: string = "") =>
  atom({
    key,
    default: defaultValue
  });

const TextInput = ({ id, defaultValue }: any) => {
  const [text, setText] = useRecoilState(textState(id, defaultValue));

  const onChange = (event: any) => {
    setText(event.target.value);
  };

  useEffect(() => {
    return () => console.log("TextInput unmount");
  }, []);

  return (
    <div>
      <input type="text" value={text} onChange={onChange} />
      <br />
      Echo: {text}
    </div>
  );
};

export default function App() {
  const [tabIndex, setTabIndex] = useState(0);

  // This would normally be a fetch request made by graphql or inside useEffect
  const fields = [
    { id: "foo", type: "text", value: "bar" },
    { id: "hello", type: "text", value: "world" }
  ];

  return (
    <div className="App">
      <RecoilRoot>
        <form>
          <button type="button" onClick={() => setTabIndex(0)}>
            Tab 1
          </button>
          <button type="button" onClick={() => setTabIndex(1)}>
            Tab 2
          </button>

          {tabIndex === 0 ? (
            <div>
              <h1>Fields</h1>
              {fields.map((field) => {
                if (field.type === "text") {
                  return (
                    <TextInput
                      key={field.id}
                      id={field.id}
                      defaultValue={field.value}
                    />
                  );
                }
              })}
            </div>
          ) : (
            <div>
              <h1>Tab 2</h1>Just checking if state is persisted when TextInput
              is unmounted
            </div>
          )}
        </form>
      </RecoilRoot>
    </div>
  );
}

Is this even possible with recoil. I mean it seems to work but I can't ignore the warnings.

Sos answered 18/3, 2022 at 12:32 Comment(0)
H
7

This answer shows how you can manually manage multiple instances of atoms using memoization.

However, if your defaultValue for each usage instance won't change, then Recoil already provides a utility which can take care of this creation and memoization for you: atomFamily. I'll quote some relevant info from the previous link (but read it all to understand fully):

... You could implement this yourself via a memoization pattern. But, Recoil provides this pattern for you with the atomFamily utility. An Atom Family represents a collection of atoms. When you call atomFamily it will return a function which provides the RecoilState atom based on the parameters you pass in.

The atomFamily essentially provides a map from the parameter to an atom. You only need to provide a single key for the atomFamily and it will generate a unique key for each underlying atom. These atom keys can be used for persistence, and so must be stable across application executions. The parameters may also be generated at different callsites and we want equivalent parameters to use the same underlying atom. Therefore, value-equality is used instead of reference-equality for atomFamily parameters. This imposes restrictions on the types which can be used for the parameter. atomFamily accepts primitive types, or arrays or objects which can contain arrays, objects, or primitive types.

Here's a working example showing how you can use your id and defaultValue (a unique combination of values as a tuple) as a parameter when using an instance of atomFamily state for each input:

TS Playground

body { font-family: sans-serif; }
input[type="text"] { font-size: 1rem; padding: 0.5rem; }
<div id="root"></div><script src="https://unpkg.com/[email protected]/umd/react.development.js"></script><script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script><script src="https://unpkg.com/[email protected]/umd/recoil.min.js"></script><script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script><script>Babel.registerPreset('tsx', {presets: [[Babel.availablePresets['typescript'], {allExtensions: true, isTSX: true}]]});</script>
<script type="text/babel" data-type="module" data-presets="tsx,react">

// import ReactDOM from 'react-dom';
// import type {ReactElement} from 'react';
// import {atomFamily, RecoilRoot, useRecoilState} from 'recoil';

// This Stack Overflow snippet demo uses UMD modules instead of the above import statments
const {atomFamily, RecoilRoot, useRecoilState} = Recoil;

const textInputState = atomFamily<string, [id: string, defaultValue?: string]>({
  key: 'textInput',
  default: ([, defaultValue]) => defaultValue ?? '',
});

type TextInputProps = {
  id: string;
  defaultValue?: string;
};

function TextInput ({defaultValue = '', id}: TextInputProps): ReactElement {
  const [value, setValue] = useRecoilState(textInputState([id, defaultValue]));

  return (
    <div>
      <input
        type="text"
        onChange={ev => setValue(ev.target.value)}
        placeholder={defaultValue}
        {...{value}}
      />
    </div>
  );
}

function App (): ReactElement {
  const fields = [
    { id: 'foo', type: 'text', value: 'bar' },
    { id: 'hello', type: 'text', value: 'world' },
  ];

  return (
    <RecoilRoot>
      <h1>Custom defaults using atomFamily</h1>
      {fields.map(({id, value: defaultValue}) => (
        <TextInput key={id} {...{defaultValue, id}} />
      ))}
    </RecoilRoot>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

</script>
Highoctane answered 18/3, 2022 at 16:54 Comment(3)
why pass id to the atomFamily though, if we can't even use it for anything?Concentric
^ @Concentric It's unused by the function in the example code, but can be used in whichever way is appropriate for your actual scenario — it's there to demonstrate a pattern of discriminating unique usage sites. See in the docs: Family DefaultsHighoctane
Yeah, unfortunately recoil is confusing here, because the natural assumption is that you could provide a unique key name to each member of the family but you can't. However you can at least also pass the same params to the effects, so at least you can restore state from localstorage, etc: effects: (param) => [ localStorageEffect<ImgCollection>(${STORAGE_ROOT}.imgCollections__${param.id}), ], github.com/facebookexperimental/Recoil/issues/231Concentric
C
0

I think the problem is from textState(id, defaultValue). Every time you trigger re-rendering for TextInput, that function will be called again to create a new atom with the same key.

To avoid that situation, you can create a global variable to track which atom added. For example

let atoms = {}
const textState = (key: string, defaultValue: string = "") => {
   //if the current key is not added, should add a new atom to `atoms`
   if(!atoms[key]) {
      atoms[key] = atom({
         key,
         default: defaultValue
      })
   }

   //reuse the existing atom which is added before with the same key
   return atoms[key];
}
Coze answered 18/3, 2022 at 13:48 Comment(1)
Thanks as well for your answer. I'd be very hesitant to rely on global variables in react applications, but at least it gets the job done. The atomFamily approach seems like the best solution.Sos

© 2022 - 2024 — McMap. All rights reserved.