0

EDIT: Link to CodeSandbox. Bug can be reproduced by clicking on any of the notes on the guitar or buttons on the TabBar. You will see the consol.logs appear twice the first time and once after.

I'm working on a project as a way to learn React. It is an app for creating guitar tabs. And I have two questions regarding state management, specifically useReducer.

I've put myself in a situation where I use the useReducer hook with a large reducer to deal with the most complex state of my app and use multiple useState hooks to store states I first tought to be dealing with separated concerns. After a while it got very obvious that all those states are almost exclusively updated inside my reducer. Is this anti-pattern and maybe even causing trouble for my React app? Should I just include the useStates in my useReducer state?

My reducer is also acting strange. It is firing twice on the first call done to the app. More specifically one dispatch is registered, but reducer is ran twice, one finishing after the other leading to unwanted behaviour. I've tried debugging it with Chrome Dev Tools with no luck.

Pasted the code from my reducer and states below, and here is a link to the GitHub project if that helps! (.JSX file with reducer and states is located in /src/Tabber.jsx).

I know this is a big ask, I'm very greatful if you will have a look.

      function Tabber() {
      const [legendNotation, setLegendNotation] = useState(''); // Adding notations to tab.
      const [keyCounter, setKeyCounter] = useState(1); // Counter for React-element keys.
      const [chordBuilder, setChordBuilder] = useState({ active: false, string: ['', '', '', '', '', ''] }); // Will store state of chord formed on guitarneck before it is added to tab.
      const [marker, setMarker] = useState({ tabIdx: 0, yIdx: 0, stringIdx: 0 }); // Tab marker moving accross target tab on the y-axis.
      const [tabState, dispatch] = useReducer(reducer, initState); // Storing state for each tab added.
      const [tuning, setTuning] = useState(['E', 'B', 'G', 'D', 'A', 'E']); // Chosen guitar tuning.
    
      // Reducer managing TabBar state.
      function reducer(tabState, action) {
        console.log('REDUCER START: ', action);
        let newTabState = JSON.parse(JSON.stringify(tabState)); // Deepcopy otherwise the tabLines get mutated, causing bugs.
    
        // User click on "move up"-button. Tab is moved one index backwards.
        switch (action.type) {
          case ACTIONS.MOVE_UP:
            console.log('Reducer: MOVE UP');
            if (tabState.length <= 1 || action.payload === 0) return newTabState;
            const [movingUp] = newTabState.splice(action.payload, 1);
            newTabState.splice(action.payload - 1, 0, movingUp);
            setMarker((prevMarker) => {
              return { ...prevMarker, tabIdx: action.payload, yIdx: newTabState[action.payload].tabLines[0].length };
            });
            return newTabState;
    
          // User click on "move down"-button. Tab is moved one index forward.
          case ACTIONS.MOVE_DOWN:
            console.log('Reducer: MOVE DOWN');
            if (tabState.length <= 1 || action.payload === newTabState.length) return newTabState;
            const [movingDown] = newTabState.splice(action.payload, 1);
            newTabState.splice(action.payload + 1, 0, movingDown);
            setMarker((prevMarker) => {
              return { ...prevMarker, tabIdx: action.payload, yIdx: newTabState[action.payload].tabLines[0].length };
            });
            return newTabState;
    
          // User click "remove"-button. Tab is removed from state.
          case ACTIONS.REMOVE:
            console.log('Reducer: REMOVE');
    
            // Deleting tab indexed before marker will move marker to match the new index for the marked tab.
            if (action.payload < marker.tabIdx)
              setMarker((prevMarker) => ({
                ...prevMarker,
                tabIdx: prevMarker.tabIdx - 1,
                yIdx: tabState[action.payload].tabLines[0].length,
              }));
            // Deleting the tab marked will remove marker.
            else if (action.payload === marker.tabIdx) {
              setMarker({ tabIdx: -1, stringIdx: -1, yIdx: -1 });
            }
            return newTabState.filter((tab, i) => i !== action.payload);
    
          // User clicks "add"-button. A new tab is added to the state.
          case ACTIONS.ADD:
            console.log('Reducer: ADD');
            setKeyCounter((prevKeyCounter) => prevKeyCounter + 1);
    
            // Remove and toggle notation on previous marked tab off.
            if (legendNotation !== '') {
              newTabState[marker.tabIdx] = removeTabStringY(newTabState[marker.tabIdx], 1);
              setLegendNotation('');
            }
    
            setMarker((prevMarker) => {
              return { ...prevMarker, tabIdx: tabState.length, stringIdx: 0, yIdx: 0 };
            });
    
            return [
              ...newTabState,
              {
                id: keyCounter,
                key: keyCounter,
                title: '',
                tabLines: ['', '', '', '', '', ''],
              },
            ];
    
          // Updates TabBar title on input-onChange.
          case ACTIONS.RENAME:
            console.log('Reducer: RENAME');
            return newTabState.map((tab) => (tab.id === action.payload.id ? { ...tab, title: action.payload.title } : tab));
    
          // Update text in tabs whenever a note on the guitar is clicked.
          case ACTIONS.ADD_NOTE:
            console.log('Reducer: NEWNOTE');
    
            // If no tab is marked no changes are done.
            if (marker.tabIdx === -1) return tabState;
    
            // Handling notes following a notation.
            if (legendNotation !== '') {
              // If note is NOT on same string as the notation the notation is wiped.
              if (action.payload.stringId !== marker.stringIdx) {
                newTabState[marker.tabIdx] = removeTabStringY(newTabState[marker.tabIdx], 1);
                setMarker((prevMarker) => ({ ...prevMarker, yIdx: prevMarker.yIdx - 1 }));
              }
    
              // If note is on the same string as the notation the note is added.
              else {
                // Adds clicked note-fret to targeted tabBar string. Add matching dashes to sibling strings and moves marker.
                newTabState[marker.tabIdx] = addFretAndDashes(
                  newTabState[marker.tabIdx],
                  action.payload.fretId,
                  action.payload.stringId,
                );
    
                setMarker((prevMarker) => ({
                  ...prevMarker,
                  stringIdx: action.payload.stringId,
                  yIdx: newTabState[marker.tabIdx].tabLines[0].length,
                }));
              }
              setLegendNotation('');
            }
    
            // Handling notes if ChordBuilder is turned on.
            else if (chordBuilder === 'shift') {
              // !!NOT IMPLEMENTED YET!!
            }
    
            // Handling notes without notation.
            else {
              // Adds clicked note-fret to targeted tabBar string. Add matching dashes to sibling strings and moves marker.
              newTabState[marker.tabIdx] = addFretAndDashes(
                newTabState[marker.tabIdx],
                action.payload.fretId,
                action.payload.stringId,
              );
    
              setMarker((prevMarker) => ({
                ...prevMarker,
                stringIdx: action.payload.stringId,
                yIdx: newTabState[marker.tabIdx].tabLines[0].length,
              }));
            }
            return newTabState;
    
          case ACTIONS.NOTATION:
            console.log('Reducer: NOTATION');
            // Not allowed to add notation at the beginning of tabBar.
            if (marker.yIdx <= 0) {
              setLegendNotation('');
              return newTabState;
            }
    
            // If user clicks the same notation-button again it will toggle off.
            if (legendNotation === action.payload.notation) {
              newTabState[marker.tabIdx] = removeTabStringY(newTabState[marker.tabIdx], 1);
              setMarker((prevMarker) => ({ ...prevMarker, yIdx: prevMarker.yIdx - 1 }));
              setLegendNotation('');
              return newTabState;
            }
    
            // If user clicks another notationbutton before adding the followup note the notations will swap.
            if (legendNotation !== '' && action.payload.notation !== legendNotation) {
              newTabState[marker.tabIdx] = removeTabStringY(newTabState[marker.tabIdx], 1);
              setMarker((prevMarker) => ({ ...prevMarker, yIdx: prevMarker.yIdx - 1 }));
            }
    
            // Adds notation following previous note.
            newTabState[marker.tabIdx].tabLines = newTabState[marker.tabIdx].tabLines.map(
              (line, i) => (line += i === marker.stringIdx ? action.payload.notation : '-'),
            );
            setMarker((prevMarker) => ({ ...prevMarker, yIdx: prevMarker.yIdx + 1 }));
            setLegendNotation(action.payload.notation);
            return newTabState;
    
          case ACTIONS.CHORD:
            // !!NOT IMPLEMENTED YET!!
            return tabState;
    
          // Helper method to find index off tab. CHECK IF THIS CAN BE MOVED OUTSIDE OF REDUCER!
          case ACTIONS.SET_TAB_INDEX:
            console.log('Reducer: SET TAB INDEX');
            const newTabIdx = tabState.findIndex((tab) => action.payload.tabId === tab.id);
            if (newTabIdx === -1) return tabState;
    
            // Remove and toggle notation on previous marked tab off.
            if (legendNotation !== '') {
              newTabState[marker.tabIdx] = removeTabStringY(newTabState[marker.tabIdx], 1);
              setLegendNotation('');
            }
    
            // Sets last edited string as marked string. THIS CODEBIT NEEDS REFACTORING.
            let prevStringIdx;
            tabState[newTabIdx].tabLines.map((line, i) => {
              if (!line.endsWith('-') && prevStringIdx === -1) prevStringIdx = i;
            });
    
            setMarker({ tabIdx: newTabIdx, stringIdx: prevStringIdx, yIdx: tabState[newTabIdx].tabLines[0].length });
            return newTabState;
    
          default:
            console.log('REDUCER ERROR!', action);
            return tabState;
        }
      }
      /*
      Rest of the code...
      */
    }
JonLunde
  • 151
  • 1
  • 3
  • 10
  • 1
    The "firing twice" thing is likely to do with it being a dev build. Does the problem persist if you do a production build? – SimonR Jul 14 '21 at 12:33
  • @SimonR Yes. Unfortunately it persists even in production mode. – JonLunde Jul 14 '21 at 12:41
  • 1
    By making a smaller example that runs in codesandbox, you're both more likely to get people to help *and* more likely to find the problem yourself. – Chris Farmer Jul 14 '21 at 12:48
  • 1
    @ChrisFarmer Great idea, thanks! – JonLunde Jul 14 '21 at 12:49
  • 1
    It's just so nice to be able to click on a link and see a problem live and immediately try to debug it. – Chris Farmer Jul 14 '21 at 12:52
  • This is so cool, I had no idea! Here is the link, I also edited my main post. The bug can be reproduced by clicking on any of the notes on the guitar, or by clicking on any of the buttons on the TabBar. You will then see my console.log's appear twice the first time. https://codesandbox.io/s/recursing-wildflower-km6mr?file=/README.md – JonLunde Jul 14 '21 at 15:21

1 Answers1

0

I solved the issues I faced.

It felt wrong to have useStates that really only was updated inside my reducer, I ended up adding them to my reducer state. It makes so much more sense and is easier to manage.

For the reducer double firing I found a solution for it here. By moving the whole reducer into its own file and export it where I need it. The linked post goes deeper into why it works!

JonLunde
  • 151
  • 1
  • 3
  • 10