List doesn't re-render with state change

bsb
reasonreact

#1

Hi all,

Back again with another newby issue… I’m working on another example for Next.js (learning so I can use it in an upcoming project). I’ve got a list in a render method that’s not rendering even though I know that the code is being executed. What could cause this?

Pull Request: https://github.com/zeit/next.js/pull/7346/files

List Component Code

type action =
	 | Change(string)
	
	type state = {
	  newTodo: string,
	};
	
	let reducer = (_state, action) =>
	  switch(action) {
	  | Change(string) => { newTodo: string }
	  };

[@react.component]
	let make = (~day) => {
	  let (state, dispatch) = React.useReducer(reducer, { newTodo: "" });
	  let (appState, appDispatch) = TodoApp.useTodoReducer();
	  let todos = appState->TodoApp.getDay(day);
	
	  <div>
	    <ul>
	      (
	        todos->Belt.Array.map(todo => {
	          Js.Console.log2("TODO ::", todo);
	         <li key=TodoApp.Todo.idGet(todo)>
	           <p> {ReasonReact.string(todo->TodoApp.Todo.textGet)}</p>
	         </li>
	        })
	      )->ReasonReact.array
	    </ul>
	
	    <p>
	      <input
	        placeholder="What needs to be done?"
	        value=state.newTodo
	        onChange={ev => dispatch(Change(ReactEvent.Form.target(ev)##value))}
	      />
	      <button onClick={
	        _ => appDispatch(TodoApp.Add(day, TodoApp.Todo.make(state.newTodo)))
	      }>
	        {React.string("Add")}
	      </button>
	    </p>
	  </div>

#2

@scull7 This is not an problem originating in ReasonReact but rather with the way ReactJS is being used.

The global state in TodoApp.re is being mutated, so even if you return something from the reducer, it is actually the same reference. React won’t re-render children when this happens, as mentioned in the docs: https://reactjs.org/docs/hooks-reference.html#bailing-out-of-a-dispatch

About how to fix this in Reason, I’d prob get rid of the bs.deriving stuff in the global state type, and use spread operator in the updater add, remove and complete.

This is a version of the file that worked for me locally (haven’t tested it much but it compiles and the UI updates when adding an item):

TodoApp.re (click to expand)
module TodoId = {
  type t = string;

  let prefix = ref(0);

  let make = () => {
    let prefix = Js.Math.random_int(1000000, 9999999)->string_of_int;
    let suffix = Js.Date.now()->int_of_float->string_of_int;

    {j|$prefix::$suffix|j};
  };
};
module Todo = {
  [@bs.deriving abstract]
  type t = {
    id: TodoId.t,
    mutable finished: bool,
    text: string,
  };

  let make = (~finished=false, text) =>
    t(~id=TodoId.make(), ~finished, ~text);

  let complete = t => {
    t->finishedSet(true);
    t;
  };

  let isSame = (t1, t2) => t1->idGet === t2->idGet;
};

module TodoList = {
  type t = array(Todo.t);

  let contains = (list, todo) => list->Belt.Array.some(Todo.isSame(todo));

  let complete = (list, target) =>
    list->Belt.Array.map(todo =>
      todo->Todo.isSame(target) ? todo->Todo.complete : todo
    );

  let add = (list, todo) =>
    if (!list->contains(todo)) {
      [|todo|]->Belt.Array.concat(list);
    } else {
      // panic - should be unreachable
      let id = todo->Todo.idGet;
      Js.Exn.raiseError({j|Could not add todo $id, it already exists.|j});
    };

  let remove = (list, todo) =>
    list->Belt.Array.keep(current => !todo->Todo.isSame(current));
};

type t = {
  today: TodoList.t,
  tomorrow: TodoList.t,
};

type day =
  | Today
  | Tomorrow;

type action =
  | Add(day, Todo.t)
  | Complete(day, Todo.t)
  | Remove(day, Todo.t);

let appState = {today: [||], tomorrow: [||]};

let add = (state, day, todo) => {
  switch (day) {
  | Today => {...state, today: state.today->TodoList.add(todo)}
  | Tomorrow => {...state, tomorrow: state.tomorrow->TodoList.add(todo)}
  };
};

let complete = (state, day, todo) => {
  switch (day) {
  | Today => {...state, today: state.today->TodoList.complete(todo)}
  | Tomorrow => {...state, tomorrow: state.tomorrow->TodoList.complete(todo)}
  };
};

let remove = (state, day, todo) => {
  switch (day) {
  | Today => {...state, today: state.today->TodoList.remove(todo)}
  | Tomorrow => {...state, tomorrow: state.tomorrow->TodoList.remove(todo)}
  };
};

let getDay = (state, day) => {
  switch (day) {
  | Today => state.today
  | Tomorrow => state.tomorrow
  };
};

let reducer = (state, action) => {
  switch (action) {
  | Add(day, todo) => add(state, day, todo)
  | Complete(day, todo) => complete(state, day, todo)
  | Remove(day, todo) => remove(state, day, todo)
  };
};

let useTodoReducer = () => React.useReducer(reducer, appState);

#3

@jchavarri Thank you for the knowledge. I’ll add this and check it out this weekend


#4

@jchavarri Worked after I re-introduced the ref to the app state.

let appState = ref({ today: [||], tomorrow: [||] });

//...
let reducer = (state, action) => {
  let newState = switch (action) {
  | Add(day, todo) => add(state, day, todo)
  | Complete(day, todo) => complete(state, day, todo)
  | Remove(day, todo) => remove(state, day, todo)
  };
  appState := newState;
  newState;
};

let useTodoReducer = () => React.useReducer(reducer, appState^);

This allows storage of the state across the different “pages” of the next.js app.