DEV Community

wkrueger
wkrueger

Posted on • Edited on

React daily ramblings: Encapsulated list items

So this is something that should be simple but actually hit me for a while yesterday.

Let's say I want to create a To Do App. Or anything else with a list.

To Do List

Since we have a list, the task list data would be a state stored in a parent component, and then spread to the children. Something like this:

function Root() {
  const [tasks, setTasks] = useState([INITIAL_TASK])

  return <main>
    <h1>my to do</h1>
    <ul>
      {tasks.map(task => (<TaskView value={task} setValue={...}/>))}
    </ul>
  </main>
}
Enter fullscreen mode Exit fullscreen mode

I have two main goals here:

  • <TaskView /> must be properly encapsulated. It should not care about WHERE it is put in the application. Thus, it should not know about its index in the array;
  • In order to improve performance, <TaskView /> will be wrapped in a memo(). In order for memo() to work, we must ensure that its props do not change if its underlying data didnt change.

Approach 1: Setter callback

We write TaskView like this:

(PS: code in this article not tested or linted)

const TaskView = memo((
  { value, setValue }:
  { value: Task, setValue: (cb: (arg: (old: Task) => Task) => void }
) => {
  const handleChangeName = useCallback((event) => {
    const newName = event.target.value
    setValue(old => ({ ...old, name: newName }))
  }, [setValue])
  return ...
})
Enter fullscreen mode Exit fullscreen mode

This is properly encapsulated but brings some challenges when writing the consumer.

function Root() {
  const [tasks, setTasks] = useState([INITIAL_TASK])

  const setTaskAtIndex = useCallback((value: Task, index: number) => {
    setTasks(previous => {
      // ...
    })
  }, [])

  return <main>
    <h1>my to do</h1>
    <ul>
      {tasks.map((task, idx) => {
        const setValue = callback => {
          const newValue = callback(task)
          setTaskAtIndex(newValue, idx)
        }
        return <TaskView value={task} setValue={setValue}/>
      })}
    </ul>
  </main>
}
Enter fullscreen mode Exit fullscreen mode

So the problem here is that setValue will always have a new reference on every render, "rendering" the memo() useless. Since it resides inside a loop with dynamic size, I can't apply useCallback on it.

A naive approach would be adding an extra prop index to the TaskView, but this would be a hack as encapsulation would be broken.

I've tackled this by creating an "adapter component", so that useCallback could be used. Now TaskView should only re-render when its data changes.

function TaskViewAdapter(props: {
  value: Task,
  setValueAtIndex: (value: Task, index: number) => void ,
  index: number
}) {
  const setValue = useCallback((callback) => {
    const newValue = callback(value)
    setValueAtIndex(newValue, index)
  }, [value, setValueAtIndex, index])
  return <TaskView value={props.value} setValue={setValue} />
}
Enter fullscreen mode Exit fullscreen mode

What is different with HTML Events?

An old and common approach on handling lists is the use of data-tags (or other attributes). With this approach, we can reach efficient rendering without the help of an intermediate component.

function Main() {
  const handleClick = useCallback((ev) => {
    console.log('index', ev.target.dataset.index)
  }, [])
  return <ul>
    <li><button data-index="1" onClick={handleClick}>Button 1</button></li>
    <li><button data-index="2" onClick={handleClick}>Button 2</button></li>
  </ul>
}
Enter fullscreen mode Exit fullscreen mode

This only works because the data is being emitted from an HTML event.

What has changed here? Differently from our setValue callback, the HTML event brings context along with the data. It brings the whole element instead of simply the value;

This means the parent can attach data to the element, end then read that data back when handling the event. And the internal implementation of <button> still doesn't need to know that what extra info has the parent attached.

We can attempt to replicate that by, instead of simply emitting data, emitting an event-ish which has extra contextual data about the component. Since custom event emitting is not inside any React "standard", we'd have to pinpoint a standard event format for the particular project.

const event = createEvent({
  component: getSelfRef(),
  data,
})
onChange(event)
Enter fullscreen mode Exit fullscreen mode

Also, (when using Hook Components) there is no way to get the current component reference without involving the creation of a wrapper "Adapter" component. So in the end we fall again into the same case of needing an Adapter.

Top comments (0)