React form development. KISS, YAGNI, DRY principles in practice

Hello, in this tutorial we will look at how to develop a very simple, but controlled form in React, focusing on the quality of the code.

When developing our form, we will follow the principles of “KISS”, “YAGNI”, “DRY”. To successfully complete this tutorial you do not need to know these principles, I will explain them along the way. However, I believe that you have a good command of modern javascript and can think in React .



Tutorial structure:







To the cause! Writing a simple form using KISS and YAGNI


So, let's imagine that we have a task to implement an authorization form:
Copy Code
const logInData = {
  nickname: 'Vasya',
  email: 'pupkin@gmail.com',
  password: 'Reac5$$$',
};



We begin our development by analyzing the principles of KISS and YAGNI, temporarily forgetting about the rest of the principles.

KISS - “Leave the code simple and dumb.” I think you are familiar with the concept of simple code. But what does “dumb” code mean? In my understanding, this is code that solves the problem using the minimum number of abstractions, while the nesting of these abstractions in each other is also minimal.

YAGNI - “You won’t need it.” The code should be able to do only what it is written for. We do not create any functionality that may be needed later or which makes the application better in our opinion. We do only what is needed specifically for the implementation of the task.

Let's strictly follow these principles, but also consider:

  • initialDataand onSubmitfor LogInFormcomes from the top (this is a useful technique, especially when the form should be able to process createand updateat the same time)
  • must have for each field label

In addition, let's miss the stylization, since it is not interesting to us, and valid, as this is a topic for a separate tutorial.

Please implement the form yourself, following the principles described above.

My form implementation
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Enter your nickname
        <input
          value={logInData.nickname}
          onChange={e => setLogInData({ ...logInData, nickname: e.target.value })}
        />
      </label>
      <label>
        Enter your email
        <input
          type="email"
          value={logInData.email}
          onChange={e => setLogInData({ ...logInData, email: e.target.value })}
        />
      </label>
      <label>
        Enter your password
        <input
          type="password"
          value={logInData.password}
          onChange={e => setLogInData({ ...logInData, password: e.target.value })}
        />
      </label>
      <button>Submit</button>
    </form>
  );
};
    






Your decision, most likely, is somewhat different, because each developer thinks differently. You can create your own state for each field or place handler functions in a separate variable, in any case this is not important.

But if you immediately created one handler function for all fields, then this is no longer the “dumbest" solution to the problem. And our goal at this stage is to create the simplest and most "dumb" code so that later we can look at the whole picture and select the best abstraction.

If you have the exact same code, this is cool and means that our thinking converges!

Further we will work with this code. It is simple, but still far from ideal.




Refactoring and DRY


It's time to deal with the DRY principle.

DRY, simplified wording - "do not duplicate your code." The principle seems simple, but it has a catch: to get rid of code duplication, you need to create abstractions. If these abstractions are not good enough, we will violate the KISS principle.

It is also important to understand that DRY is not needed to write code faster. Its task is to simplify the reading and support of our solution. Therefore, do not rush to create abstractions right away. It is better to make a simple implementation of some part of the code, and then analyze what abstractions you need to create in order to simplify the reading of the code and reduce the number of places for changes when they are needed.

Checklist of correct abstraction:
  • the name of the abstraction is fully consistent with its purpose
  • abstraction performs a specific, understandable task
  • reading the code from which the abstraction was extracted improved

So, let's get down to refactoring.
And we have pronounced duplicate code:
Copy Code
  <label>
    Enter your email
    <input
      type="email"
      value={logInData.email}
      onChange={e => setLogInData({ ...logInData, email: e.target.value })}
    />
  </label>
  <label>
    Enter your password
    <input
      type="password"
      value={logInData.password}
      onChange={e => setLogInData({ ...logInData, password: e.target.value })}
    />
  </label>



This code duplicates a composition of 2 elements: label, input. Let's merge them into a new abstraction InputField:
Copy Code
  <label>
    Enter your email
    <input
      type="email"
      value={logInData.email}
      onChange={e => setLogInData({ ...logInData, email: e.target.value })}
    />
  </label>
  <label>
    Enter your password
    <input
      type="password"
      value={logInData.password}
      onChange={e => setLogInData({ ...logInData, password: e.target.value })}
    />
  </label>



Now ours LogInFormlooks like this:
Copy Code
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={logInData.nickname}
        onChange={e => setLogInData({ ...logInData, nickname: e.target.value })}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={logInData.email}
        onChange={e => setLogInData({ ...logInData, email: e.target.value })}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={logInData.password}
        onChange={e => setLogInData({ ...logInData, password: e.target.value })}
      />
      <button>Submit</button>
    </form>
  );
};



Reading has become easier. The name of the abstraction corresponds to the task that it solves. The purpose of the component is obvious. The code has become less. So we are going in the right direction!

Now it’s clear that the InputField.onChangelogic is duplicated.
What happens there can be divided into 2 stages:

Copy Code
const stage1 = e => e.target.value;
const stage2 = password => setLogInData({ ...logInData, password });



The first function describes the details of getting the value from the event input. We have a choice of 2 abstractions in which we can store this logic: InputFieldand LogInForm.

In order to determine correctly which of the abstractions we need to refer our code to, we will have to turn to the full formulation of the DRY principle: “Each part of knowledge should have a unique, consistent and authoritative representation within the system.”

Part of the knowledge in the concrete example is knowing how to get the value from the event in input. If we will store this knowledge in ours LogInForm, then it is obvious that when using ourInputFieldin another form, we will have to duplicate our knowledge, or to bring it into a separate abstraction and use it from there. And based on the KISS principle, we should have the minimum possible number of abstractions. Indeed, why do we need to create another abstraction if we can just put this logic in ours InputFieldand the external code will not know anything about how input works inside InputField. It will simply take on a ready-made meaning, the same as it passes inward.

If you are confused that you may need an event in the future, remember the YAGNI principle. You can always add an additional prop onChangeEventto our component InputField.

Until then it InputFieldwill look like this:
Copy Code
const InputField = ({ label, type, value, onChange }) => (
  <label>
    {label}
    <input
      type={type}
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  </label>
);



Thus, uniformity of the type is observed during input and output into the component and the true nature of what is happening for the external code is hidden. If in the future we need another ui component, for example, checkbox or select, then in it we will also maintain type uniformity on input-output.

This approach gives us additional flexibility, since our form can work with any input-output sources without the need to produce additional unique handlers for each.

This heuristic trick is built-in by default in many frameworks. For example, this is the main idea v-modelin Vuewhich many people love for its ease of working with forms.

Let's get back to business, update our component LogInFormin accordance with the changes in InputField:
Copy Code
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={logInData.nickname}
        onChange={nickname => setLogInData({ ...logInData, nickname })}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={logInData.email}
        onChange={email => setLogInData({ ...logInData, email })}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={logInData.password}
        onChange={password => setLogInData({ ...logInData, password })}
      />
      <button>Submit</button>
    </form>
  );
};



It already looks pretty good, but we can do even better!

Callbackthat is passed in onChangealways does the same thing. Only the key is changed in it: password, email, nickname. So, we can replace it with a call to the function: handleChange('password').

Let's implement this function:
Copy Code
  const handleChange = fieldName => fieldValue => {
    setLogInData({
      ...logInData,
      [fieldName]: fieldValue,
    });
  };



As you can see, the function receives the argument and stores it in the closure of the handler, and the handler immediately returns to the external code. Functions that seem to take arguments one at a time for a call are also called curried.

Let's look at the resulting code:
Copy Code
  const LogInForm = ({ initialData, onSubmit }) => {
    const [logInData, setLogInData] = useState(initialData);
  
    const handleSubmit = e => {
      e.preventDefault();
      onSubmit(logInData);
    };
  
    const handleChange = fieldName => fieldValue => {
      setLogInData({
        ...logInData,
        [fieldName]: fieldValue,
      });
    };
  
    return (
      <form onSubmit={handleSubmit}>
        <InputField
          label="Enter your nickname"
          value={logInData.nickname}
          onChange={handleChange('nickname')}
        />
        <InputField
          type="email"
          label="Enter your email"
          value={logInData.email}
          onChange={handleChange('email')}
        />
        <InputField
          type="password"
          label="Enter your password"
          value={logInData.password}
          onChange={handleChange('password')}
        />
        <button>Submit</button>
      </form>
    );
  };
  
  // InputField.js
  const InputField = ({ type, label, value, onChange }) => (
    <label>
      {label}
      <input type={type} value={value} onChange={e => onChange(e.target.value)} />
    </label>
  );



This code is short, concise and moderately declarative implementation of the task. Further refactoring in the context of the task, in my opinion, does not make sense.




What else can be done?



If you have many forms in the project, then you can put the handleChange calculation into a separate hook useFieldChange:
Copy Code
  // hooks/useFieldChange.js
  const useFieldChange = setState => fieldName => fieldValue => {
    setState(state => ({
      ...state,
      [fieldName]: fieldValue,
    }));
  };
  // LogInForm.js
  const handleChange = useFieldChange(setLogInData);



Since this is a pure function (meaning the first time it is called because it always returns the same function), it does not have to be a hook. But the hook looks conceptually a more correct and natural solution for React.

You can also add callbackin-place support fieldValueto completely replicate the usual behavior setStatefrom React:
Copy Code
  const isFunc = val => typeof val === "function";

  const useFieldChange = setState => fieldName => fieldValue => {
    setState(state => ({
      ...state,
      [fieldName]: isFunc(fieldValue) ? fieldValue(state[fieldName]) : fieldValue,
    }));
  };



An example of use with our form:
Copy Code
  const LogInForm = ({ initialData, onSubmit }) => {
    const [logInData, setLogInData] = useState(initialData);
    const handleChange = useFieldChange(setLogInData);
  
    const handleSubmit = e => {
      e.preventDefault();
      onSubmit(logInData);
    };
  
    return (
      <form onSubmit={handleSubmit}>
        <InputField
          label="Enter your nickname"
          value={logInData.nickname}
          onChange={handleChange('nickname')}
        />
        <InputField
          type="email"
          label="Enter your email"
          value={logInData.email}
          onChange={handleChange('email')}
        />
        <InputField
          type="password"
          label="Enter your password"
          value={logInData.password}
          onChange={handleChange('password')}
        />
        <button>Submit</button>
      </form>
    );
  };



But if your application has only one form, you don’t need to do all this! Because it contradicts the YAGNI principle, following which we should not do what we do not need to solve a specific problem. If you have only one form, then the real benefits of such movements are not enough. After all, we reduced the code of our component only by 3 lines, but introduced an additional abstraction, hiding a certain logic of the form, which is better to keep on the surface.




Oh no! Do not do so, please!



Shaped Config Forms


Locked configs are like a webpack config, only for form.

Better with an example, look at this code:
Copy Code
  const Form = () => (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={state.nickname}
        onChange={handleChange('nickname')}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={state.email}
        onChange={handleChange('email')}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={state.password}
        onChange={handleChange('password')}
      />
      <button>Submit</button>
    </form>
  );



It might seem to some that the code is duplicated here, because we call the same InputField component, passing the same label, value and onChange parameters there. And they start to-DRY-yav their own code to avoid imaginary duplication.
Often they do this like this:
Copy Code
const fields = [
  {
    name: 'nickname',
    label: 'Enter your nickname',
  },
  {
    type: 'email',
    name: 'email',
    label: 'Enter your email',
  },
  {
    type: 'password',
    name: 'password',
    label: 'Enter your password',
  },
];

const Form = () => (
  <form onSubmit={handleSubmit}>
    {fields.map(({ type, name, label }) => (
      <InputField
        type={type}
        label={label}
        value={state[name]}
        onChange={handleChange(name)}
      />
    ))}
    <button>Submit</button>
  </form>
);



As a result, with 17 lines of jsx code we get 16 lines of the config. Bravo! This is what I understand DRY. If we have 100 of these inputs here, then we get 605 and 506 lines, respectively.

But, as a result, we got more complex code, violating the KISS principle. After all, now it consists of 2 abstractions: fields and an algorithm (yes, an algorithm is also an abstraction), which turns it into a tree of React elements. When reading this code, we will have to constantly jump between these abstractions.

But the biggest problem with this code is its support and extension.
Imagine what will happen to him if:
  • some more field types with different possible properties will be added
  • fields are rendered or not rendered based on the input of other fields
  • some fields require some additional processing when changing
  • values ​​in selects depend on previous selects

This list can be continued for a long time ...

To implement this, you will always have to store some functions in your config, and the code that renders the config will gradually turn into Frankenstein, taking a bunch of different props and throwing them over different components.

The code that was written before, based on the composition of components, will quietly expand, change as you like, while not becoming very complicated due to the lack of intermediate abstraction - an algorithm that builds a tree of React elements from our config. This code is not duplicate. It just follows a certain design pattern, and therefore it looks "patterned".

Useless optimization


After hooks appeared in React, there was a tendency to wrap all handlers and components indiscriminately in useCallbackand memo. Please, do not do that! These hooks were not provided by React developers because React is slow and everything needs to be optimized. They provide room for optimizing your application in case you run into performance issues. And even if you encounter such problems, you do not need to wrap the entire project in memoand useCallback. Use Profiler to identify problems and only then memoize in the right place.

Memoizing will always make your code more complicated, but not always more productive.

Let's look at the code from a real project. Compare what the function looks like with useCallbackand without:
Copy Code
  const applyFilters = useCallback(() => {
    const newSelectedMetrics = Object.keys(selectedMetricsStatus).filter(
      metric => selectedMetricsStatus[metric],
    );
    onApplyFilterClick(newSelectedMetrics);
  }, [selectedMetricsStatus, onApplyFilterClick]);

  const applyFilters = () => {
    const newSelectedMetrics = Object.keys(selectedMetricsStatus).filter(
      metric => selectedMetricsStatus[metric],
    );
    onApplyFilterClick(newSelectedMetrics);
  };



The readability of the code clearly increased after the removal of the wrapper, because the ideal code is its lack.

The performance of this code has not increased, because this function is used like this:
Copy Code
  <RightFooterButton onClick={applyFilters}>APPLY</RightFooterButton>



Where RightFooterButton- it's just styled.buttonfrom styled-components, which will update very quickly. But memory consumption increase our application because React will always keep in memory selectedMetricsStatus, onApplyFilterClickand the version of the function applyFilters, relevant for these dependencies.

If these arguments are not enough for you, read the article that discusses this topic more widely.




findings


  • React forms are easy. Problems with them arise due to the developers themselves and the React documentation, in which this topic is not covered in sufficient detail.
  • aka value onChange . useFieldChange, , , v-model Vue .
  • KISS YAGNI. DRY, , .
  • , , React- .
  • , .




P.S.


Initially, I planned to write about declarative validation of complex nested forms. But, in the end, I decided to prepare the audience for this, so that the picture was as complete as possible.
The following tutorial will be about validating the simple form implemented in this tutorial.
Many thanks to everyone who read to the end!

It doesn’t matter how you write code or what you do, the main thing is to enjoy it.

All Articles