Multiple form values in one react recoil atom override each other

Viewed 1846

Is there any way to hold multiple form input values in one React Recoil atom? I keep trying to add 2 form field values, but they just override each other.

I have a registration form which has 2 fields; email and phone.

My (simplified) form component looks like so;

import { atom, useSetRecoilState, useRecoilValue } from 'recoil';

const registerAtom = atom({
    key: 'register',
    default: [],
});

function Registration() {
    const setEmail = useSetRecoilState(registerAtom);
    const email = useRecoilValue(registerAtom);

    const setPhone = useSetRecoilState(registerAtom);
    const phone = useRecoilValue(registerAtom);

    return (
        <>
            <form>
                <input name="email" type="text" className="form-control" value={email} onChange={e => setEmail(e.target.value)} placeholder="Email Address" />
                <input name="phone" type="text" className="form-control" value={phone} onChange={e => setPhone(e.target.value)} placeholder="Phone Number" />
            </form>
        </>
    )
}
2 Answers

If you are certain that you will never need to read or write the email and phone states independently, a simple approach is to use a single atom with an object value (this is equivalent to using React's useState hook with an object value):

import {atom} from 'recoil';

const contactInfoState = atom({
  key: 'contactInfo',
  default: {
    email: '',
    phone: '',
  },
});

Then, use like this (updating the entire object every time):

import {useRecoilState} from 'recoil';

function Registration () {
  const [{email, phone}, setContactInfo] = useRecoilState(contactInfoState);
  
  return (
    <form>
      <input
        type="text"
        value={email}
        onChange={ev => setContactInfo({email: ev.target.value, phone})}
        placeholder="Email Address"
      />
      <input
        type="text"
        value={phone}
        onChange={ev => setContactInfo({email, phone: ev.target.value})}
        placeholder="Phone Number"
      />
    </form>
  )
}

However, the idiomatic way to do this (and where Recoil becomes more powerful) is composition of atoms using a selector, which can provide a way for reading and writing the values together (just like in the example above), yet still allows for reading and writing them independently using their atoms:

import {atom, DefaultValue, selector} from 'recoil';

const emailState = atom({
  key: 'email',
  default: '',
});

const phoneState = atom({
  key: 'phone',
  default: '',
});

const contactInfoState = selector({
  key: 'contactInfo',
  get: ({get}) => {
    // get values from individual atoms:
    const email = get(emailState);
    const phone = get(phoneState);
    // then combine into desired shape (object) and return:
    return {email, phone};
  },
  set: ({set}, value) => {
    // in a Reset action, the value will be DefaultValue (read more in selector docs):
    if (value instanceof DefaultValue) {
      set(emailState, value);
      set(phoneState, value);
      return;
    }
    // otherwise, update individual atoms from new object state:
    set(emailState, value.email);
    set(phoneState, value.phone);
  },
});

Here's a complete and self-contained example in a snippet, which you can run on this page to verify that it works:

Note: It uses the UMD versions of React, ReactDOM, and Recoil, so they are exposed globally using those names instead of using import statements.

<script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/recoil@0.5.2/umd/recoil.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.16.3/babel.min.js"></script>

<div id="root"></div>

<script type="text/babel" data-type="module" data-presets="react">

const {
  atom,
  DefaultValue,
  RecoilRoot,
  selector,
  useRecoilValue,
  useSetRecoilState,
} = Recoil;

const emailState = atom({
  key: 'email',
  default: '',
});

const phoneState = atom({
  key: 'phone',
  default: '',
});

const contactInfoState = selector({
  key: 'contactInfo',
  get: ({get}) => {
    const email = get(emailState);
    const phone = get(phoneState);
    return {email, phone};
  },
  set: ({set}, value) => {
    if (value instanceof DefaultValue) {
      set(emailState, value);
      set(phoneState, value);
      return;
    }
    set(emailState, value.email);
    set(phoneState, value.phone);
  },
});

function Registration () {
  const {email, phone} = useRecoilValue(contactInfoState);
  const setEmail = useSetRecoilState(emailState);
  const setPhone = useSetRecoilState(phoneState);
  
  return (
    <form>
      <input
        type="text"
        value={email}
        onChange={ev => setEmail(ev.target.value)}
        placeholder="Email Address"
      />
      <input
        type="text"
        value={phone}
        onChange={ev => setPhone(ev.target.value)}
        placeholder="Phone Number"
      />
    </form>
  )
}

function DisplayState () {
  const email = useRecoilValue(emailState);
  const phone = useRecoilValue(phoneState);
  return (
    <pre>
      <code>{JSON.stringify({email, phone}, null, 2)}</code>
    </pre>
  );
}

function Example () {
  return (
    <RecoilRoot>
      <Registration />
      <DisplayState />
    </RecoilRoot>
  );
}

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

</script>

Your atom has a single value register holds an array at the beginning, and later be assigned with the values of the inputs.

Both inputs set the state of the atom registerAtom, makes it override each other.

What you need to do is holding an object as a value for register, that has two keys: email and phone. Then, you can update each key with the relevant value from the specific input that has changed.

So. instead of:

const registerAtom = atom({
    key: 'register',
    default: [],
});

Create this atom:

const registerAtom = atom({
    key: 'register',
    default: {
        email: '',
        phone: ''
    },
});

This created the object with initial values of empty strings for email and phone.

Now you define the set functions like this:

const setRegistrationInfo = useSetRecoilState(registerAtom);
const registrationInfo = useRecoilValue(registerAtom);

Finally, all you need to do is to change the specific key of the object when you are setting its state. Make sure you are creating a new Object since you are updating a state and the state expects a new updated object, so we will use Object.assign:

        <form>
            <input name="email" type="text" className="form-control" value={registrationInfo.email} onChange={e => setRegistrationInfo(Object.assign(registrationInfo, {email: e.target.value}))} placeholder="Email Address" />
            <input name="phone" type="text" className="form-control" value={registrationInfo.phone} onChange={e => setRegistrationInfo(Object.assign(registrationInfo, {phone: e.target.value}))} placeholder="Phone Number" />
        </form>

Final code:

import { atom, useSetRecoilState, useRecoilValue } from 'recoil';

const registerAtom = atom({
    key: 'register',
    default: {
        email: '',
        phone: ''
    },
});

function Registration() {

    const setRegistrationInfo = useSetRecoilState(registerAtom);
    const registrationInfo = useRecoilValue(registerAtom);

    return (
        <>
            <form>
                <input name="email" type="text" className="form-control" value={registrationInfo.email} onChange={e => setRegistrationInfo(Object.assign(registrationInfo, {email: e.target.value}))} placeholder="Email Address" />
                <input name="phone" type="text" className="form-control" value={registrationInfo.phone} onChange={e => setRegistrationInfo(Object.assign(registrationInfo, {phone: e.target.value}))} placeholder="Phone Number" />
            </form>
        </>
    )
}
Related