Github Repo My LinkedIn
This environment has been configured using Vite React app and TailwindCSS.


The challenge seems simple. We have a block with a button to add a new item row, one to delete it, one to delete the block, one to add a new block and three inputs.

Challenge UI

The static code looks like this:

App.jsx

function App() {
  return (
     <main className=" h-screen overflow-hidden flex flex-col items-center justify-center m-auto bg-stone-800">
      <div className="w-full container">
        <h1 className="text-center text-2xl font-bold text-white">
          Game Logic Editor
        h1>

     <section className="m-auto w-2/5 p-6 bg-gray-900 rounded-2xl mt-6 flex flex-col gap-4">
      <div className="flex justify-between items-center">
        <h1 className="text-white text-xl font-bold">Item Mechanics Blockh1>
        <button className="text-red-500 text-2xl font-mono font-medium cursor-pointer">
          x
        button>
       div>
       <div className="flex max-xl:flex-col max-xl:items-center gap-3 items-end-safe p-4 text-gray-600 bg-slate-700 rounded-2xl">
      <div className="flex flex-col w-full gap-1 ">
        <label htmlFor="type" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.type}
        label>
        <select
          name="type"
          id="type"
          defaultValue="select"
          className="bg-white p-2 rounded-lg "
        >
          <option value="select" hidden>
            ...Select
          option>
          {ITEM_MECHANICS_VALUES.type.map((type, index) => (
            <option key={type + index} value={type}>
              {type}
            option>
          ))}
        select>
      div>
      <div className="flex flex-col w-full gap-1">
        <label htmlFor="effect" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.effect}
        label>
        <select
          name="effect"
          id="effect"
          defaultValue="select"
          className="bg-white p-2 rounded-lg"
        >
          <option value="select" hidden>
            -
          option>
          {ITEM_MECHANICS_VALUES.effect.map((ef, index) => (
            <option value={ef} key={ef + index}>
              {ef}
            option>
          ))}
        select>
      div>
      <div className="flex flex-col w-full gap-1">
        <label htmlFor="usageLimit" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.usageLimit}
        label>
        <input
          id="usageLimit"
          type="number"
          min={1}
          max={10}
          className="bg-white rounded-lg p-2"
        />
      div>
      <div>
        <button class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded cursor-pointer">
          Delete
        button>
      div>
    div>

        <div className="m-auto">
          <button class="bg-emerald-500 hover:bg-lime-500 text-white px-4 py-2 rounded cursor-pointer">
          Add a new item
          button>
        div>
      section>

      <div className="flex justify-center mt-6">
          <button class="bg-cyan-400 hover:bg-cyan-500 text-white px-4 py-2 rounded cursor-pointer">
            Add a new block
          button>
      div>
    main>
  );
}

export default App;

const ITEM_MECHANICS_LABELS = {
  type: "Item Type",
  effect: "Buff/Debuff",
  usageLimit: "Max Usage",
};

const ITEM_MECHANICS_VALUES = {
  type: ["Weapon", "Armor", "Consumable", "Artifact", "Material"],
  effect: ["Health Boost", "Speed Buff", "Defense Increase", "Attack Damage", "Special Ability"],
};

So let's get started.

Step 1: Break the UI into a component hierarchy

We can break this UI into two components. The block and the item row.

Now the code looks like this:

App.jsx

import { Block } from "./components/Block";

function App() {
  return (
    <main className=" h-screen overflow-hidden flex flex-col items-center justify-center m-auto bg-stone-800">
      <div className="w-full container">
        <h1 className="text-center text-2xl font-bold text-white">
          Game Logic Editor
        h1>

        <Block />

        <div className="flex justify-center mt-6">
          <button class="bg-cyan-400 hover:bg-cyan-500 text-white px-4 py-2 rounded cursor-pointer">
            Add a new block
          button>
        div>
      div>
    main>
  );
}

export default App;

Block.jsx

import { Row } from "./Row";

export function Block() {
  return (
    <section className="m-auto w-2/5 p-6 bg-gray-900 rounded-2xl mt-6 flex flex-col gap-4">
      <div className="flex justify-between items-center">
        <h1 className="text-white text-xl font-bold">Item Mechanics Blockh1>
        <button className="text-red-500 text-2xl font-mono font-medium cursor-pointer">
          x
        button>
      div>

      <Row />

      <div className="m-auto">
        <button class="bg-emerald-500 hover:bg-lime-500 text-white px-4 py-2 rounded cursor-pointer">
          Add a new item
        button>
      div>
    section>
  );
}

Row.jsx

import { ITEM_MECHANICS_LABELS, ITEM_MECHANICS_VALUES } from "../constants";

export function Row() {
  return (
    <div className="flex max-xl:flex-col max-xl:items-center gap-3 items-end-safe p-4 text-gray-600 bg-slate-700 rounded-2xl">
      <div className="flex flex-col w-full gap-1 ">
        <label htmlFor="type" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.type}
        label>
        <select
          name="type"
          id="type"
          defaultValue="select"
          className="bg-white p-2 rounded-lg "
        >
          <option value="select" hidden>
            ...Select
          option>
          {ITEM_MECHANICS_VALUES.type.map((type, index) => (
            <option key={type + index} value={type}>
              {type}
            option>
          ))}
        select>
      div>
      <div className="flex flex-col w-full gap-1">
        <label htmlFor="effect" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.effect}
        label>
        <select
          name="effect"
          id="effect"
          defaultValue="select"
          className="bg-white p-2 rounded-lg"
        >
          <option value="select" hidden>
            -
          option>
          {ITEM_MECHANICS_VALUES.effect.map((ef, index) => (
            <option value={ef} key={ef + index}>
              {ef}
            option>
          ))}
        select>
      div>
      <div className="flex flex-col w-full gap-1">
        <label htmlFor="usageLimit" className="font-medium text-gray-200">
          {ITEM_MECHANICS_LABELS.usageLimit}
        label>
        <input
          id="usageLimit"
          type="number"
          min={1}
          max={10}
          className="bg-white rounded-lg p-2"
        />
      div>
      <div>
        <button class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded cursor-pointer">
          Delete
        button>
      div>
    div>
  );
}

Step 2: Find the minimal but complete representation of UI state.

The inputs, the inputs row and the block are state because they change over time and can’t be computed from anything.

const [itemBlocks, setItemBlocks] = useState([0]);
  const [itemRows, setItemRows] = useState([{}]);
  const [item, setItem] = useState({
    type: "",
    effect: "",
    usageLimit: 0,
  });

Naturally, we can represent our state in this way above. And now let's apply it in the components. The modifications:

// App.jsx
      {...}
        {itemBlocks.map((block, index) => (
          <Block 
            key={index}
            block={block}
            item={item}
            itemRows={itemRows}
          />
        ))}
       {...}
// Block.jsx
export function Block({ item, itemRows, block }) {
    {...}
        <h1 className="text-white text-xl font-bold">
            Item Mechanics Block {block + 1}
        h1>
    {...}
      {itemRows.map((ir, index) => (
        <Row key={index} item={item} />
      ))}
    {...}
}
// Rows.jsx

export function Row({ item }) {
     {...}
        <select
          name="type"
          id="type"
          value={item.type}
          defaultValue="select"
          className="bg-white p-2 rounded-lg "
        >
      {...}
        <select
          name="effect"
          id="effect"
          defaultValue="select"
          value={item.effect}
          className="bg-white p-2 rounded-lg"
        >
       {...}
        <input
          id="usageLimit"
          type="number"
          name="usageLimit"
          min={1}
          max={10}
          value={item.usageLimit}
          className="bg-white rounded-lg p-2"
        />
       {...}
}

Challenge UI with state

With the state created and the props passed, the application looks like this. So now let's add some interactivity.

Step 3: Create the handle functions to manipulate the state and add inverse data flow

The app renders correctly with props and state flowing down the hierarchy. But to change the state according to user input, we will need to support data flowing the other way: the row component deep in the hierarchy needs to update the state in App

The functions are:

const handleInputsChange = () => {...}

  const handleAddRow = () => {...}
  const handleDeleteRow = () => {...}

  const handleAddBlock = () => {...}
  const handleDeleteBlock = () => {...}
  1. We need to implement the handleInputsChange:
const handleItemChange = (name, value) => {
    setItem({
      ...item,
      [name]: value,
    });
  };
{itemBlocks.map((block, index) => (
          <Block
            key={index}
            block={block}
            item={item}
            onItemChange={handleItemChange} // Add in the Block props
            itemRows={itemRows}
          />
        ))}

        // And the Row props
        {itemRows.map((ir, index) => (
         <Row key={index} item={item} onItemChange={onItemChange} /> 
        ))}

Finally, call on the inputs (in the selects in the same way)

<input
          id="usageLimit"
          name="usageLimit"
          type="number"
          min={1}
          max={10}
          value={item.usageLimit}
          onChange={({ target }) => onItemChange(target.name, target.value)}
          className="bg-white rounded-lg p-2"
        />

App with controlled inputs

And Voilà the inputs now are controlled, the next function is the add and remove item row:

const handleAddRow = () => {
    setItemRows([
      ...itemRows,
      {
        type: "",
        effect: "",
        usageLimit: 0,
      },
    ]);
  };

Now we just add as props to the block and call on the Add item row button and:

App with new row added

Done! But now we face the following problem:

Shared data

The inputs values are mirrored, but this can be easily resolved by adding an ID to the item state and nesting it with the rows:

const [itemRows, setItemRows] = useState([{
    id: crypto.randomUUID(),  
    type: "",
    effect: "",
    usageLimit: 0,
  }]); 

  const handleItemChange = (id, name, value) => {
    setItemRows(itemRows.map((item) => {
      if (item.id === id)
        return { ...item, [name]: value };

      return item;
    }));
  };

  const handleAddRow = () => {
    setItemRows([
      ...itemRows,
      {
        id: crypto.randomUUID(), // add this line
        type: "",
        effect: "",
        usageLimit: 0,
      },
    ]);
  };

// With this alteration, we can implement the delete handler

  const handleDeleteRow = (id) => {
    setItemRows(itemRows.filter((item) => item.id !== id));
  };

Now just do the props modifications and in the Row component change the handleItemChange to accept the id, and now all is working well.

The App after the changes

Last is the block handler. We learned we need an ID to delete and avoid duplicating, so let's do it:

const [itemBlocks, setItemBlocks] = useState([{ id: 0 }]);

  const handleAddBlock = () => {
    setItemBlocks([...itemBlocks, { id: itemBlocks.length }]);
  };

  const handleDeleteBlock = (id) => {
    setItemBlocks(itemBlocks.filter((block) => block.id !== id));
  };

Now is just to set the props and add to the button on click. Let's see if it works:

Bug in the app

Hmm, worked, but not like as expected. This code broke what we had fixed. When we try to add a new item row duplicate in the blocks, the inputs are duplicating too. And when we try to delete the row in the same way.

The Problem

This occurs because the children in the ItemBlock didn't change together when they needed to. Like the rows and inputs before, we can fix this by nesting the state that needs to change together. Example: if the first block has 4 rows, we want to keep it, and when we add the new block, this block needs to have one row, and all this data needs to be isolated. So finally we achieve a state like this:

const [itemBlocks, setItemBlocks] = useState([
    {
      id: 0,
      rows: [
        {
          id: crypto.randomUUID(),
          type: "",
          effect: "",
          usageLimit: 0,
        },
      ]
    }
  ]);

But now, imagine the pain of modifying the inputs. In this state, we have a deep nesting complexity and performance issues with many blocks/rows; the update functions do a lot of work (multiple array iterations).

The handle function starts to look like this:

const handleItemChange = (id, blockId, name, value) => {
    setItemBlocks(
      itemBlocks.map((block) => {
        if (block.id === blockId) {
          const updatedRows = block.rows.map((row) => {
            if (row.id === id) {
              return { ...row, [name]: value };
            }
            return row;
          });
          return { ...block, rows: updatedRows };
        }
        return block;
      })
    );
  };

Step 4: The Solution, Normalise the state and modify the handlers.

With the normalised state we have something like this:

const normalisedState = {
  itemBlocks: {
    0: {
      id: 0,
      rowsIds: [1]
    }
  },
  itemRows: {
    1: {
      type: "",
      effect: "",
      usageLimit: 0,
    }
  }
}

Notice, now we just have one level of nesting over two of the past state. The idea is that the blocks just refer to the row ids like a SQL table referring to itself. Now the handleChange function looks like this:

const handleItemChange = (id, name, value) => {
    setItemBlocks({
      ...itemTree,
      itemRows: {
        ...itemTree.itemRows,
        [id]: {
          ...itemTree.itemRows[id],
          [name]: value
        }
      }
    })
  };

Conclusion

Using many nested objects/arrays in the state brings complexity and possibly performance issues. The idea is to maintain the state as flat as possible. And using Normalised Tree is a way to do that. If you have some functionality similar to this little challenge or have another way to solve that problem put in the comments and let's discuss it!

References

https://react.dev/learn/thinking-in-react
https://react.dev/learn/updating-objects-in-state
https://react.dev/learn/updating-arrays-in-state