Skip to content

Instantly share code, notes, and snippets.

@timhwang21
Last active July 7, 2022 16:25
Show Gist options
  • Save timhwang21/6a0d5530eb2f3dffaefd680837f23120 to your computer and use it in GitHub Desktop.
Save timhwang21/6a0d5530eb2f3dffaefd680837f23120 to your computer and use it in GitHub Desktop.
Handling conditional field visibility in dynamic forms

Handling conditional field visibility in dynamic forms

This post explores several common ways of handling visibility of conditional fields. Our sample form will have the following schema:

  • foo: always present
  • bar: dependent on form state (value of foo)
  • baz: dependent on other application state (e.g. user permissions)

Below is our form, prior to implementing visibility logic:

import React from 'react';

export default class MyForm extends Component {
  render() {
    const { formValues } = this.props;

     return (
      <form>
        <input name="foo" value={formValues.foo} />
        <input name="bar" value={formValues.bar} />
        <input name="baz" value={formValues.baz} />
      </form>
    );
  }
}

Assumptions

Examples below assume the following:

  1. We are using Redux to manage application state.
  2. Form state is stored in Redux (with something like Redux Form).

These are reasonable assumptions of any reasonably complex React application; however, the ideas below can apply even with other setups.

Approach 1: Imperative state setting

We store field visibility in local component state. Whenever a condition that a form field depends on changes, we update that state.

 import React from 'react';

 export default class MyForm extends Component {
+  constructor(props) {
+    super(props);
+  
+    this.state = {
+      barVisible: props.formValues.foo === 'yes',
+      bazVisible: props.user.permissions.canSeeBar,
+    }
+  }
+  
+  componentWillReceiveProps(nextProps) {
+    const { formValues, user } = this.props;
+  
+    if (formValues.foo !== nextProps.formValues.foo) {
+      this.setState({ barVisible: nextProps.formValues.foo === 'yes' });
+    }
+  
+    if (user.permissions.canSeeBar !== nextProps.user.permissions.canSeeBar) {
+      this.setState({ bazVisible: nextProps.user.permissions.canSeeBar });
+    }
+  }
+  
   render() {
     const { formValues } = this.props;
+    const { barVisible, bazVisible } = this.state;
 
     return (
       <form>
         <input name="foo" value={formValues.foo} />
-        <input name="bar" value={formValues.bar} />
+        {barVisible && <input name="bar" value={formValues.bar} />}
-        <input name="baz" value={formValues.baz} />
+        {bazVisible && <input name="baz" value={formValues.baz} />}
       </form>
     );
   }
 }

From my experiences, this tends to be the first solution people new to React reach for due to its conceptual simplicity. However, this approach scales poorly: because the developer is responsible for imperatively managing field visibility, as the number of fields and interactions grows, so does the possibility of error and state synchronization issues. Additionally, this approach tends to lead to giant, unmaintainable componentWillReceiveProps functions.

Approach 2: Function of props

Manual management of visibility can be avoided by passing conditionals directly to the form fields. Upon closer examination, our fieldVisibility state actually doesn't provide us with any extra information whatsoever: any information we get from that object can be derived directly from props, 100% of the time. Thus, it doesn't make sense to store this information at all.

 import React from 'react';

 export default class MyForm extends Component {
   render() {
-    const { formValues } = this.props;
+    const { formValues, user } = this.props;
+ 
+    const barVisible = formValues.foo === 'yes';
+    const bazVisible = user.permissions.canSeeBar;

     return (
       <form>
         <input name="foo" value={formValues.foo} />
-        <input name="bar" value={formValues.bar} />
+        {barVisible && <input name="bar" value={formValues.bar} />}
-        <input name="baz" value={formValues.baz} />
+        {bazVisible && <input name="baz" value={formValues.baz} />}
       </form>
     );
   }
 }

This solution is more declarative and requires less code, and is perfectly sufficient for many use cases. However, one shortcoming is that field visibility is entirely held within the form component: components outside the form that need access to field visibility state cannot get it.

As an example, recently we have been exploring a "summary view" component that shows progress through the form to the user. With the above approach, the summary component must be a child of the form, because there is no way to communicate this state back "upwards."

Approach 3: Lifting state up

Note: the following example uses Redux. However, the general idea of lifting state up and providing data accessors can apply even without Redux.

Once we have external components that are interested in field visibility state, it makes sense to lift this state out of the form.

 import React from 'react';
+import { connect } from 'react-redux';

+// formSelectors.js
+exportgetBarVisible = state => state.formValues.bar === 'yes';
+getBazVisible = state => state.user.permissions.canSeeBaz;
+myFormFieldVisibility = state => ({
+  getBarVisible: getBarVisible(state),
+  getBazVisible: getBazVisible(state),
+});

+@connect(state => ({ fieldVisibility: myFormFieldVisibility(state) }))
 export default class MyForm extends Component {
   render() {
-    const { formValues } = this.props;
+    const { formValues, fieldVisibility } = this.props;

     return (
       <form>
         <input name="foo" value={formValues.foo} />
-        <input name="bar" value={formValues.bar} />
+        {fieldVisibility.barVisible && <input name="bar" value={formValues.bar} />}
-        <input name="baz" value={formValues.baz} />
+        {fieldVisibility.bazVisible && <input name="baz" value={formValues.baz} />}
       </form>
     );
   }
 }

With this approach, we gain several powerful new capabilities:

  1. If fields bar and baz occur in other places in the applications and have the same display rules, the selectors defined above can be reused.
  2. External components such as the summary view component can directly reuse the myFormFieldVisibility selector to determine what fields are currently visible.
  3. Even if <MyForm/> is the only consumer of the field visibility selector, this method achieves separation of concerns: render() is now only concerned with if a field should be rendered, and not about the conditions under which rendering occurs. This moves <MyForm/> closer to being a pure display component, making it easier to reason about.

Note: fieldVisibility as coded above will cause unnecessary rerenders because the myFormFieldVisibility selector returns a new object every time. This can be avoided with a cached selector library such as Reselect. This example uses vanilla Javascript just for example purposes.

Bonus: hidden prop

Conditional logic in JSX can be ugly and hard to read, due to mixing paradigms of logic and markup. One way to avoid this is to defined a hidden property:

// old
{condition && <Foo/>}

// new
<Foo hidden={!condition} />

We can define an <Input/> component that is a shallow wrapper of the HTML <input/> that adds this behavior. However, because this is the sort of functionality that we want on all form controls, it makes sense to keep this logic in a decorator:

const Hidable = Component => ({ hidden, ...props }) => !!hidden || <Component {...props}/>;

With this, we can clean up our last example:

+const DecoratedInput = Hidable(props => <input {...props}/>);

 @connect(state => ({ fieldVisibility: myFormFieldVisibility(state) }))
 export default class MyForm extends Component {
   render() {
     const { formValues, fieldVisibility } = this.props;

     return (
       <form>
         <input name="foo" value={formValues.foo} />
-        {fieldVisibility.barVisible && <input name="bar" value={formValues.bar} />}
+        <DecoratedInput name="bar" value={formValues.bar} hidden={!fieldVisibility.barVisible}/>
-        {fieldVisibility.bazVisible && <input name="baz" value={formValues.baz} />}
+        <DecoratedInput name="baz" value={formValues.baz} hidden={!fieldVisibility.bazVisible}/>
       </form>
     );
   }
 }

This can be specialized further into a FormControl decorator that applies common functionality to any control, such as providing a <label/>, providing a tooltip, or allowing size-controlling props.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment