useCallback does not update on dependency in specific circustamces

I have a select function that is initialized using useCallback using single dependency: name. Then I pass this function to some nested components to be called on click on a chart element. If I pass the function the way I do (see the example below), it turns out that dependency name of useCallback is never recognized (if I update input field by entering a new value, console.log outputs the old name when I click on the chart elements). Any ideas why React might behave this way? Does this have something to do with class instantiation and usage? The thing is that I tried using the same logic without ChartWrapper and Chart class instantiation with naked svg (without d3) – it works fine. So, the problem is somewhere in ChartWrapper or updated version of Chart using class instead of component function. Any ideas are welcome. Here is the full code of the minimal app that doesn’t work:

App.js:

import React, {useState, useCallback} from 'react';
import './App.css';
import ChartWrapper from './ChartWrapper';

function App() {

  const [name, setName] = useState("some_name")

  const nameChangeHandler = (event) => {
    setName(event.target.value)
    console.log("Updated name to " + event.target.value)
  }

  const select = useCallback((node) => {
    console.log("selected " + node + " for name " + name)
  }, [name])

  return (
    <div className="App">
      <ChartWrapper onSelect={select} /><br />
      Name: <input name="name" value={name} onChange={nameChangeHandler}/>
    </div>
  );
}

export default App;

ChartWrapper.js:

import React, { useRef, useState, useEffect } from 'react';
import Chart from './Chart';

const ChartWrapper = (props) => {
    const chartArea = useRef(null)
    const [chart, setChart] = useState(null)

    useEffect(() => {
        if (!chart) {
            setChart(new Chart(chartArea.current, props.onSelect))
        }
        else {
            chart.update(props.onSelect)
        }
    }, [chart, props.onSelect])

    return (
        <div className="chart-area" ref={chartArea}></div>
    )
}

export default ChartWrapper

Chart.js:

import * as d3 from 'd3';
import React from "react";

class Chart {
  constructor(element, onSelect) {
    let vis = this
    vis.nodes = [2, 4, 6]
    vis.circleSize = 30;
    vis.distanceBetweenCircles = 5;

    vis.svg = d3.select(element).append('svg')
      .attr('width', vis.nodes.length * (vis.circleSize + vis.distanceBetweenCircles))
      .attr('height', vis.circleSize)

    vis.update(onSelect)
  }

  update(onSelect) {
    let vis = this;
    
    const circlesEnter = vis.svg.selectAll('circle').data(vis.nodes).enter()
    circlesEnter.append('circle')
      .attr('fill', "gainsboro")
      .attr('stroke', "grey")
      .style('cursor', 'pointer')
      .attr('cx', (d, i) => vis.circleSize/2 + vis.distanceBetweenCircles*i + vis.circleSize*i) 
      .attr('cy', vis.circleSize/2) 
      .attr('r', vis.circleSize/2) 
      .on('click', (event, d) => onSelect(d))

    const textsEnter = vis.svg.selectAll('text').data(vis.nodes).enter()
    textsEnter.append('text')
      .attr('text-anchor', "middle")
      .attr('dominant-baseline', "middle")
      .attr('font-size', "12")
      .style('cursor', 'pointer')
      .attr('x', (d, i) => vis.circleSize/2 + vis.distanceBetweenCircles*i + vis.circleSize*i)
      .attr('y', vis.circleSize/2)
      .text(d => d)
  }
}

export default Chart;

Answer

The problem is in using d3’s enter() when performing updates – because the 3 circles already exist at that time, calls after .enter() won’t add any new circle (and all those attr-s and onclick would be added only to the new circles).

You can fix it by drawing whole svg in your constructor and in update do just updating of click function.

import * as d3 from 'd3';
import React from "react";

export default class Chart {
  constructor(element, onSelect) {
    let vis = this
    vis.nodes = [2, 4, 6]
    vis.circleSize = 30;
    vis.distanceBetweenCircles = 5;

    vis.svg = d3.select(element).append('svg')
      .attr('width', vis.nodes.length * (vis.circleSize + vis.distanceBetweenCircles))
      .attr('height', vis.circleSize)

    const circlesEnter = vis.svg.selectAll('circle').data(vis.nodes).enter()
    circlesEnter.append('circle')
      .attr('fill', "gainsboro")
      .attr('stroke', "grey")
      .style('cursor', 'pointer')
      .attr('cx', (d, i) => vis.circleSize / 2 + vis.distanceBetweenCircles * i + vis.circleSize * i)
      .attr('cy', vis.circleSize / 2)
      .attr('r', vis.circleSize / 2)

    const textsEnter = vis.svg.selectAll('text').data(vis.nodes).enter()
    textsEnter.append('text')
      .attr('text-anchor', "middle")
      .attr('dominant-baseline', "middle")
      .attr('font-size', "12")
      .style('cursor', 'pointer')
      .attr('x', (d, i) => vis.circleSize / 2 + vis.distanceBetweenCircles * i + vis.circleSize * i)
      .attr('y', vis.circleSize / 2)
      .text(d => d)

    vis.update(onSelect)
  }

  update(onSelect) {
    let vis = this;

    vis.svg.selectAll('circle')
      .on('click', (event, d) => onSelect(d))
  }
}