final-form / react-final-form

🏁 High performance subscription-based form state management for React
https://final-form.org/react
MIT License
7.38k stars 480 forks source link

Update field value according to another field #348

Closed jferrettiboke closed 6 years ago

jferrettiboke commented 6 years ago

Hi,

I am having an issue with chained selects. Is there any way to change values of other fields according to another field's value?

I created a sandbox so that it is easy to look at. If you select a currency from first select, let's say "usd", the country select doesn't select any option. As you can see in the state of the form, "country" value is still "IE" (the previous value).

I would like to select the first country available for a given currency. So, I thought removing <option /> it would work but it doesn't. Any idea how to solve this without wrapping the form and send data to its parent component? Is there any way to solve this just using RFF?

Thanks.

davidroeca commented 6 years ago

@jferrettiboke I've been struggling with this one recently too, and here's the approach that I'm currently taking:

const isString = (v) => typeof v === 'string'
// function keyword required for recursion
function DependentField({
  parentFields,
  ...remainingProps
}) {
  if (!parentFields?.length) {
    return <Field {...remainingProps} />
  }
  const [nextParent, ...remainingParents] = parentFields
  const parentName = isString(nextParent) ? nextParent : nextParent.parentName
  // If injectedProp not specified, use parentName
  const injectedProp = isString(nextParent) ?
    nextParent :
    nextParent.injectedProp || parentName
  return (
    <Field
      name={parentName}
      subscription={{value: true}}
      render={({input: {value}}) => {
        const renderProps = {
          ...remainingProps,
          [injectedProp]: value,
        }
        return (
          <DependentField parentFields={remainingParents} {...renderProps} />
        )
      }}
    />
  )
}

If any listed parent field changes, then the changed value is passed to the child as an updated prop. It can be used like so:

const MyDependentField = () => (
  <DependentField
    parentFields=['parentField1', { parentName: 'parentField2', injectedProp: 'parentFieldTwo' }]
    name='myDependentField'
  >
    {({ input, meta, parentField1, parentFieldTwo}) => (
      {/* render whatever you want here */}
    )}
  </DependentField>
)

I wish there were a nicer way of doing this out of the box or via an extension, but the DependentField approach here has worked for me nicely.

A few related issues: https://github.com/final-form/react-final-form/issues/297 https://github.com/final-form/react-final-form/issues/273

EDIT: added support for injectedProp for use in field arrays

phoulgaux commented 6 years ago

We use a FormSpy component to do for example something like this:

<Field name="first">
  { /* ... */ }
</Field>
<FormSpy
  subscription={{ /* ... */ }}
  render={({ form }) => (
    <Field name="second">
      { /* you can do something like this here: */ }
      {({ input }) => (
        <Input {...input} enabled={form.getFieldState('first').valid} />
      )}
    </Field>
  )}
/>

In short: with FormSpy you get the FormApi object to interact with the form, for example, use getFieldState method.

davidroeca commented 6 years ago

My challenge with FormSpy is the subscription prop. In the above case, you’d need to subscribe to {{ values: true }} at the very least for this component to render appropriately.

When subscribed to values, the FormSpy component will trigger a rerender if any field’s value has changed, regardless of whether or not I even use that field’s value to render my dependent component.

phoulgaux commented 6 years ago

Yeah, everything depends on the scale, but for small to medium forms it’s easily sufficient.

davidroeca commented 6 years ago

Agreed, as long as render doesn’t trigger unnecessary side effects, it should be fine for smaller forms (e.g. a dropdown that depends on another field and a server response should cache the server responses appropriately).

It just seems counter to the design of this library to subscribe to more fields than necessary.

erikras commented 6 years ago

As you can see in the state of the form, "country" value is still "IE" (the previous value).

Seems like this could be handled with a Declarative Form Rule, no?

jferrettiboke commented 6 years ago

Thanks @erikras. This seems to be the solution.

davidroeca commented 6 years ago

:man_facepalming: can't believe I didn't see that before. thanks @erikras

jferrettiboke commented 6 years ago

In case someone is interested how I solved my needs, check this sandbox out.

crobinson42 commented 5 years ago
/*
    For use inside a react-final-form context - change a field based on another field.

    Example: Change everytime the other field changes

      <WhenFieldChanges
          field='department'
          set='subDepartment'
          to={-1}
        />

    Example: Only change if `shouldChangeHandler` condition is true

      <WhenFieldChanges
        field='department'
        set='subDepartment'
        shouldChangeHandler=(department => { 
          if (department === -1) return true
          else return false
        }}
        to={-1}
      />
 */
import React from 'react'
import PropTypes from 'prop-types'
import { Field, useFormState } from 'react-final-form'
import { OnChange } from 'react-final-form-listeners'

const WhenFieldChanges = ({ shouldChangeHandler, field, set, to }) => {
  const { values } = useFormState()

  return (
    <Field name={set} subscription={{}}>
      {(
        // No subscription. We only use Field to get to the change function
        { input: { onChange } },
      ) => (
        <OnChange name={field}>
          {() => {
            if (shouldChangeHandler && shouldChangeHandler(values[field]))
              onChange(to)
            else onChange(to)
          }}
        </OnChange>
      )}
    </Field>
  )
}

WhenFieldChanges.propTypes = {
  field: PropTypes.string.isRequired,
  set: PropTypes.string.isRequired,
  shouldChangeHandler: PropTypes.func,
  to: PropTypes.any.isRequired,
}
WhenFieldChanges.defaultProps = {}

export default WhenFieldChanges
dbertella commented 4 years ago

hey @engineforce since this is a hot topic and I don't think the previous solution is very neat (it seems a lot of boilerplate to me), can you share a solution using the hook?

engineforce commented 4 years ago

@dbertella, turn out the solution posted by jferrettiboke is more declarative than mine and quite clean. You can find my solution using form.change (not hook, my mistake) at my sandbox.

dbertella commented 4 years ago

I actually found out myself how to do this using hooks and it looks very easy, maybe less polish but very neat, this is an example:

const FormComponent = ({handleSubmit}) => {
 const input1Field = useField('input1')
 const input2Field = useField('input1')
return  (
<form onSubmit={handleSubmit}>
  <input
    {...input1Field.input}
    onChange={(e: ChangeEvent<HTMLInputElement>) => {
        input2Field.input.onChange(e.target.value)
    }}
  />
  <input {...input21Field.input} />
</form>
)
}

Super easy actually and it works quite well! what do you think about it? (I didn't test this particular code but it should work more or less, I can make a sandbox perhaps)

EDIT: forked your example above using hooks (thanks for the tip again!) https://codesandbox.io/s/react-final-form-issue-348b-ixj9n

sgarcialaguna commented 4 years ago

I like crobinson42's solution quite a bit, but it does have two bugs:

1) onChange will always fire, even if shouldChangeHandler returns false. 2) values[field] assumes values is a flat object.

I went with a patched version that also uses form.change:

import React from "react";
import PropTypes from "prop-types";
import { useForm, useFormState } from "react-final-form";
import { OnChange } from "react-final-form-listeners";
import get from "lodash/get";

const WhenFieldChanges = ({ shouldChangeHandler, field, set, to }) => {
  const { values } = useFormState();
  const form = useForm();

  return (
    <OnChange name={field}>
      {() => {
        if (shouldChangeHandler)
          shouldChangeHandler(get(values, field)) &&
            form.change(set, to);
        else form.change(set, to);
      }}
    </OnChange>
  );
};

WhenFieldChanges.propTypes = {
  field: PropTypes.string.isRequired,
  set: PropTypes.string.isRequired,
  shouldChangeHandler: PropTypes.func,
  to: PropTypes.any.isRequired
};
WhenFieldChanges.defaultProps = {};

export default WhenFieldChanges;
jedwards1211 commented 4 years ago

@erikras I'm migrating an old codebase from redux-form and I was using a custom hook to get the value of a specific field and conditionally render some elements based on that. Basically similar to the old formValues HoC but less tedious. Using a declarative form rule wouldn't cut it in my case. And using values from FormRenderProps isn't ideal because you can't subscribe to just one field value in <Form>.

I tried doing const {input: {value}} = useField(name) but that doesn't seem to work and probably will cause problems since it calls registerField without any validation etc. unlike the main <Field> for that name.

A lot of people would find a useFieldValue hook a welcome addition, I assure you. There needs to be a way to subscribe to only the value of a single field without registering all the stuff that comes along with <Field>.

windsome commented 3 years ago

As you can see in the state of the form, "country" value is still "IE" (the previous value).

Seems like this could be handled with a Declarative Form Rule, no?

it solves the simple case. but not good for complex case. if props A changed, after a calculate with B or/and C, change X, Y, Z. how to handle this?