DEV Community

Cover image for How about use Mediator in React Form?
peterlits zo
peterlits zo

Posted on • Edited on

How about use Mediator in React Form?

EDITED

Well, maybe using Mediator is not a good idea :)

We can use event (or event-like) to send message from children to parent component.

Put the state in the parent component is not a bad idea.

See this comment please!

And thank you @lukeshiru, you are the best one!!!


Original Post

Hello everyone. When we try to code in React, we usually write some dialog component with some input component, some button component, and something else.

It is always hard to re-use those input component. Because its behavior is rely on other components.

Normal way to handle form

As you see, in the normal way, the input component need to know how does the dialog work (to set the state of the dialog). And the submit button also need to know how to get the state and send a request to server. And the submit button also need to know how to show the error message (in here, the button need to set the state errorMsg to "error".

We can pass the props in function type. To let those component can be called the 'callback' function. for example:

  • Pass the input component a function prop to deal with the event onChange. Now when we change the input component, the state of dialog will be changed.
  • Pass the submit button component a function prop to set the errorMsg.

Now those sub-component does not rely on the dialog component. Those component now just rely on those callback function. It is really good. But can it be better?

  • If the dialog component has many input components, it will hold many states. Can we just let sub-components hold the states? And can we let the sub-components have a method to get its value?
  • In normal way, only the parent component can deal with the logic. What will happen if the parent need to deal with a lot of events? Can the sub-component have methods to be called?

We do not want to let the input components rely on the dialog component, because we want to re-use those in the future. So the best way is let the sub-components rely on the interface of the parent component. In here, the sub-component rely on a function notify to call parent component. And the parent's job is just to coordinate sub-components.

For example, now I have a parent component. Its job is to send a request to server with an email address:

const Dialog = () => {
  const inputRef = useRef(null);
  const submitRef = useRef(null);

  const notify = (eventName, data) => {
    console.log(eventName, data);

    if (eventName === 'submit') {
      const email = inputRef.current.getContent();
      submitRef.current.submit(email);
    } else if (eventName === 'error') {
      inputRef.current.setErrorMsg(data);
    }
  }

  return (
    <div className={styles.dialog}>
      <Input ref={inputRef} />
      <Submit notify={notify} ref={submitRef} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you see, the dialog component rely on two components: Input and Submit. But Input and Submit do not rely on Dialog. They rely on an interface with a method notify.

It is good. It means we can use those components Input or Submit in another component, which has a method notify.

Then we need to define Input:

const Input = forwardRef((_prop, ref) => {
  const [content, setContent] = useState('');
  const [errorMsg, setErrorMsg] = useState(null);

  useImperativeHandle(ref, () => ({
    getContent: () => {
      return content;
    },
    setErrorMsg: (newErrorMsg) => {
      setErrorMsg(newErrorMsg);
    }
  }), [content]);

  return (
    <>
      <input
        ref={ref}
        className={styles.input}
        onChange={(e) => setContent(e.target.value)}
      />
      { errorMsg ? <span>{errorMsg}</span> : null }
    </>
  )
})
Enter fullscreen mode Exit fullscreen mode

It has two method: getContent and setErrorMsg. So the parent do not need to handle what is the content of the component. Neither know the errorMsg. It is just hold by the Input component.

And here is another component Submit:

const Submit = forwardRef(({ notify }, ref) => {
  useImperativeHandle(ref, () => ({
    submit: (email) => {
      console.log('Try to fetch email:', email);
      console.error(`Sorry it (${email}) is not the vaild email`);
      notify('error', `Sorry it (${email}) is not the vaild email`);
    }
  }));

  return (
    <button
      ref={ref}
      className={styles.submit}
      onClick={() => notify('submit')}
    >
      Submit
    </button>
  )
});
Enter fullscreen mode Exit fullscreen mode

Cool right? The Submit component will ask Dialog to submit. And the Dialog will call its method to submit to a server. It is helpful if you want to put the submit button into another component.

Top comments (11)

Collapse
 
paratron profile image
Christian Engel

I have a suggestion. I'd completely remove the usage of refs to start with. They make code really hard to follow and cross-connect everything. It works fine without refs :)

Lets start with the input component. It gets everything feeded from the outside:

import React from "react";

interface Props {
    value: string;
    onChange: Function;
    error?: string;
}

function Input({value, onChange, error}: Props){
    return (
        <React.Fragment>
            <input
                type="text"
                value={value}
                onChange={onChange}
            />
            {error && <span>{error}</span>}
        </React.Fragment>
    );
}
Enter fullscreen mode Exit fullscreen mode

Nice, clean and simple. If an error gets passed to the component, it will display one. If the error is taken away, it vanishes. I'd add more things like an id, name and other things but lets leave them out for the sake of a simple example.

Lets go to the dialog next:

function Dialog({onSuccess}: {onSuccess?: Function}){
    const {
        getValue, 
        getOnChange, 
        getError, 
        submit
    } = useFormSystem("https://example.com/api", onSuccess);

    return (
        <div className={styles.dialog}>
            <Input 
                value={getValue("email")} 
                onChange={getOnChange("email")} 
                error={getError("email")} 
            />
            <input type="submit" onClick={() => submit()} />
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

The dialog uses a hook named useFormSystem which takes the target URL for the form data and maybe a callback function to call so components further up in the tree might close the dialog after successful submission.

Lets see how that custom hook is built:

function useFormSystem(apiUrl, successCallback = null, initialData = {}){
    const [data, setData] = React.uSeState(initialData);
    const [errors, setErrors] = React.useState({});

    return {
        getValue: function(key){
            return data[key];
        },
        getOnChange: function(key){
            return function(e){
                const newData = {
                    ...data,
                    [key]: e.target.value
                };
                setData(newData);
            }
        },
        getError: function(key){
            return errors[key];
        },
        submit: async function(){
            const response = await fetch(apiUrl, {
                method: "post",
                headers: {
                    "content-type": "application/json"
                },
                body: JSON.stringify(data)
            });

            if(response.status === 200){
                if(successCallback){
                    successCallback();
                }
                return;
            }

            const json = await response.json();

            setErrors(json.errors);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a possible approach I would choose. Everything is written from the top of my head and has not been tested but I wrote it to give a basic example without refs. And something that is highly re-usable.

It can be easily extended so you can pass a TS interface to type your complete form data here. You might want to use constants instead of writing "email" repeatingly in the getters.

You might want to use client-side validation to not being forced to send everything to the server to get some feedback.

Collapse
 
peterlitszo profile image
peterlits zo

OMG, you are right. I really like your solution. And I believe it is the really best solution! We can try to create our hook to re-use. The basic components are just renders and send event to their parent.

I will update my post! Thank you very much!

Collapse
 
paratron profile image
Christian Engel

I'm glad I could help out. My solution is just a quick sketch of what can be done with hooks. Its certainly far from being the best solution.

But I am happy I could give you some new perspective :)

Collapse
 
peterlitszo profile image
peterlits zo

By the way, In my opinion, the function notify is not only ask parent-component to do what, and also ask parent-component for data. For example, in sub-component Submit, we want to get the email address from parent-componet, we can use:

submit: () => {
  const email = notify("getEmail"); // or notify.getEmail();
  // request...
  console.error("cannot send because the email address is not right...");
  notify("badEmail");
}
Enter fullscreen mode Exit fullscreen mode

We do not want to let the parent have the email adress, so we cannot get the email adress by props. But we can ask parent to get it.

If you want to get it, you can also use getEmail={() => notify("getEmail")}. But longer.

Collapse
 
peterlitszo profile image
peterlits zo

If the code is using typescript, it will be better. Let us make the type of notify be a object, whose attrs are all function. Each sub-component can define what type of notify they want, and the parent must finish the union of those nofity types.

Collapse
 
peterlitszo profile image
peterlits zo

Hello, Luke Shiru. I like onXxxxxx prop. It is useful if you want to let sub-component change the parent-component's state. In here, you can use <Submit onSubmit={() => notify("submit")} onError={(errorMsg) => notify("error", errorMsg)} />. But longer.

 
peterlitszo profile image
peterlits zo

Yes yes! You are right. I will update my post! Thank you very much! :)

You are so kindly! I will just use one thread next time. This is my first time to get comment.

 
peterlitszo profile image
peterlits zo

Thanks. I agree!!! Using on is much easy.

Collapse
 
peterlitszo profile image
peterlits zo

What do you think? Do you think there is some way better than this?

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

I'm not entirely sure what makes this better than just using plain JS events.

Collapse
 
peterlitszo profile image
peterlits zo

I am not sure too. Welcome to talk about it.

I meet some trouble when I want to re-use some sub-component. When I try to detach the sub-component from the dialog, I find I need to store the state in the parent component. Because other sub-components need it.

Well, but it works. The state is hold in parent-component. And pass some 'callback' functions as sub-components' props. But it looks not good.

I hope the sub-component can hold its state into itself. I do not think that put sub-components' state into parent-component is a good idea if there are a lot of sub-components.

For example, in normal way, there is a submit button. It want to send a message to a server with some information from other sub-components. There are two way to do it:

  • Let those states put into parent-component, and pass those states as the submit button's prop.
  • Put a callback function onSubmit as the prop of the submit button. How we need write the code for submit in parent.