React dynamic nested form input

Viewed 173

Im a newbie in React and Im creating a simple form that sends data to DB. I made it work almost as I wanted, the only problem is that I dont know how to update the state which has an array inside. The idea is to make a form so I can add recipes which include the whole recipe data that I map through to render each recipe. In the data object I need simple strings most of the time but then I need also three arrays or objects, I prefer the arrays in this case.

I found many solutions for class components but still I could figure out how to update the arrays. I even figured out how to update one array from a string input separated only with commas, then .split(', ') and .trim() and map() through but I could not setFormFields({}) at two places at the same time since the createRecipe() is async. The split just did not happen before the array was sent to the DB as a string. Thats why I dont put the whole code here.

I will simplify the code to make you see clear.

const defaultFormFields = {
      title: '',
      imageUrl: '',
      leadText: '',
    };

const NewRecipeForm = () => {

  const [formFields, setFormFields] = useState(defaultFormFields);
  const { title, imageUrl, leadText } = formFields;
  const [ingredients, setIngredients] = useState([])


  const handleFormFieldsChange = (event) => {
    setFormFields({ ...formFields, [event.target.name]: event.target.value })
  }
  const handleIngredientsChange = ( event) => {
  **// here I need help**
    setIngredients()
  }

  const addIngredient = () => {
    setIngredients([...ingredients, ''])
  }

  const removeIngredient = (index) => {
  **// here I need help**
}

  const createRecipe = async (event) => {
    event.preventDefault()
    // addRecipe sends the object to Firestore DB
    addRecipe('recipes', url, formFields)
    resetFormFields()
  }

  const resetFormFields = () => {
    setFormFields(defaultFormFields);
  };

  return (
    <NewRecipeFormContainer>
      <h1>New recipe</h1>
      <form onSubmit={createRecipe}>
        <h1>Title</h1>
        <input label='Title' placeholder='Recipe title' name='title' value={title} onChange={handleChange} />
        <input label='imageUrl' placeholder='imageUrl' name='imageUrl' value={imageUrl} onChange={handleFormFieldsChange} />
        <input label='leadText' placeholder='leadText' name='leadText' value={leadText} onChange={handleFormFieldsChange} />
        <h1>Ingredients</h1>
      **// here I need help ?**
        {
          ingredients.map((ingredient, index) => {
            return (
              <div key={index}>
                <input label='Ingredience' placeholder='Ingredience' name='ingredient' value={ingredient.ingredient} onChange={handleChange} />
              **// here I need help ?**
                <button onClick={removeIngredient} >remove</button>
              </div>
            )
          })
        }            
        <button onClick={addIngredient} >add</button>
      </form>
      <Button onClick={createRecipe}>ODESLAT</Button>
    </NewRecipeFormContainer>
  )
}

I will appreciate any hint or help. Ive been totally stuck for two days. Thank you!

1 Answers

Here's an example of how to update a single element in a list.

const updateSingleItemInList = (index, update) => {
  setList(list.map((l, i) => i === index ? update : l));
};

const add = (element) => setList([...list, element]);

Try simplifying your state first:

const [ingredients, setIngredients] = useState([]);
const [tips, setTips] = useState([]);

Then it becomes simple to write the handlers:

const updateIngredient = (index, text) => {
  setIngredients(list.map((ing, i) => i === index ? text : ing));
};

const addIngredient = () => setIngredients([...ingredients, ""]);

Then you can create the form object when the user wants to submit:

addRecipe('recipes', url, {
  ingredients: ingredients.map(i => ({ingredients: i})),
  // etc.
});

Put it all together and here is the minimum viable example of a component that manages a dynamic number of form elements (tested, works):

export const TextBody = () => {
  const [list, setList] = useState([{ name: "anything" }]);

  const add = () => setList(l => [...l, { name: "" }]);

  const remove = i => setList(l => [...l.slice(0, i), ...l.slice(i + 1)]);

  const update = (i, text) => setList(l => l.map((ll, ii) => (ii === i ? { name: text } : ll)));

  return (
    <>
      <TouchableOpacity onPress={add}>
        <Text text="add" />
      </TouchableOpacity>
      {list.map((l, i) => (
        <>
          <Text text={JSON.stringify(l)} />
          <TouchableOpacity onPress={() => remove(i)}>
            <Text text="remove" />
          </TouchableOpacity>
          <Input onChange={c => update(i, c.nativeEvent.text)} />
        </>
      ))}
    </>
  );
};

You can return those CRUD functions and the state from a custom hook so you only have to write this once in a codebase.

Edit: Just for fun, here's the same component with a reusable hook:

const useListOfObjects = (emptyObject = {}, initialState = []) => {
  const [list, setList] = useState(initialState);

  const add = () => setList(l => [...l, emptyObject]);

  const remove = i => setList(l => [...l.slice(0, i), ...l.slice(i + 1)]);

  const update = (i, text, field) =>
    setList(l => l.map((ll, ii) => (ii === i ? { ...ll, [field]: text } : ll)));

  return {
    list,
    add,
    remove,
    update,
  };
};

export const TextBody = () => {
  const { list, add, remove, update } = useListOfObjects({ name: "", id: Math.random() });

  return (
    <>
      <TouchableOpacity onPress={add}>
        <TextBlockWithShowMore text="add" />
      </TouchableOpacity>
      {list.map((l, i) => (
        <React.Fragment key={`${l.id}`}>
          <TextBlockWithShowMore text={JSON.stringify(l)} />
          <TouchableOpacity onPress={() => remove(i)}>
            <TextBlockWithShowMore text="remove" />
          </TouchableOpacity>
          <Input onChange={c => update(i, c.nativeEvent.text, "name")} />
        </React.Fragment>
      ))}
    </>
  );
};
Related