DEV Community

Cover image for The "hook of all trades" in React [3via series part 1]
Zdravko Kirilov
Zdravko Kirilov

Posted on • Updated on

The "hook of all trades" in React [3via series part 1]

This is part 1 of the "Building 3via" series where we'll be creating a full stack MERN application for creating tactical quizes.

The final code is here.

Part 2 of the series is here

Live demo

Intro

As a React developer I often find myself wondering if a component has grown too big and needs to be refactored.

I started my career in times when the way of thinking about UIs was a bit different and readjusting your mindset to React took a bit of an effort, which is still reflected even in the official docs.

When a person is switching to React I've often seen a natural progression of 2 extremes:

  • Trying to apply your prior vanilla/jQuery habits right into React's territory, but without thinking in components:
export const GonnaPutTheWholePageHere = () => {
    ...

    return (
       <div className="someClass">
           ...soup of tags with classes
       </div>
}
Enter fullscreen mode Exit fullscreen mode
  • After getting a bit more used to React you start seeing components everywhere and initially it's pretty exciting - you start to reap the true benefits of component-based libraries and sometimes this might lead to another kind of madness:
export const MuchFancySoReact = (props) => {
    return <header>{props.children}</header>
}
Enter fullscreen mode Exit fullscreen mode

Having passed through both of the above stages, I take a more pragmatic approach nowadays. Whenever I get into a technical discussion about how big components should be I would usually share Kent C. Dodds's article.

As with most things we end up in the subjective land of "it depends". I would like to show a practical example from my own experience building heavy CRUD UIs with React and how I try to keep things tidy.

Prerequisites

The problem

Trivia building project

It's a UI for building trivia game configurations where we can add questions and answers with their respective points. It is a quite packed user interface with lots of user actions:

  • create / delete / edit / reorder Questions
  • create / delete / reorder Answers

All those actions trigger async requests ( in our case it's all mocked for simplicity ) and need to have proper feedback informing us whether the operation succeeded. Those tend to pollute component code pretty fast. If I follow my personal rule of letting a component grow until it becomes obvious we need to break it down, I would end up with something like this:

const InitialQuizForm: FC = () => {

  const [visibleCreateForm, toggleCreateForm] = useState(false);
  ... 5 more hooks for the other operations flags

  const handleAddQuestion = (dto: CreateQuestionDto) => {
    addQuestion.mutate(dto, {
      onSuccess: () => {
        toggleCreateForm(false);
      },
    });
  };
  ... 5 more of those "handle*Something" functions
 which are doing API requests

  return (
    <Box component="main">
      ...rendering the list of questions...

      <Dialog open={visibleCreateForm} fullWidth>
        <CreateQuestionForm />
      </Dialog>
      ... 5 more dialogs for the other operations

      /* feedback for the "reorder questions" operation */
      <ToggleSnackbar open={saveQuestions.isSuccess}>
        <Alert severity="success">Questions updated.</Alert>
      </ToggleSnackbar>

    </Box>
  );
};
Enter fullscreen mode Exit fullscreen mode

I've shortened a lot of the repeating stuff for the sake of readability, but you could check out the original file - it packs more than 500 lines of very similarly repeating code for each possible user action:

  • Form/Confirmation toggling flag:
const [visibleCreateForm, toggleCreateForm] = useState(false);
Enter fullscreen mode Exit fullscreen mode
  • A handler that executes a mutation ( usually - a network request )
 const handleAddQuestion = (dto: CreateQuestionDto) => {
    addQuestion.mutate(dto, {
      onSuccess: () => {
        toggleCreateForm(false);
      },
    });
 };
Enter fullscreen mode Exit fullscreen mode
  • Some form UI, in our case - presented in a modal:
<Dialog open={visibleCreateForm} fullWidth>
  ...
  <UpsertQuestionForm
     isLoading={addQuestion.isLoading}
     error={addQuestion.error}
     onSubmit={handleAddQuestion}
  />
</Dialog>
Enter fullscreen mode Exit fullscreen mode

Create question form

The solution

Custom hooks are usually associated and used for extracting state and logic but in practice nothing stops us from returning even JSX. We could easily combine the above 3 repeating aspects into a single hook, which:

1) takes care of it's own UI toggling logic
2) takes care of mutations after a form is submitted
3) renders the form

Here's an example with the "create new question" operation turned into an almighty hook:

// useCreateQuestion.tsx

export const useCreateQuestion = () => {
  ...
  const [visibleCreateForm, toggleCreateForm] = useState(false);

  const handleAddQuestion = (dto: CreateQuestionDto) => {
    addQuestion.mutate(dto, {
      onSuccess: () => {
        toggleCreateForm(false);
      },
    });
  };

  return {
    createQuestion: () => toggleCreateForm(true),

    CreateQuestionUI: (
      <Dialog open={visibleCreateForm} fullWidth>
        <DialogTitle>
          <Stack>
            <Typography>Create a new question</Typography>
            <IconButton
              aria-label="Close form"
              onClick={() => toggleCreateForm(false)}
            >
              <Close />
            </IconButton>
          </Stack>
        </DialogTitle>

        <DialogContent>
           <UpsertQuestionForm ... />
        </DialogContent>
      </Dialog>
    ),
  };
};

Enter fullscreen mode Exit fullscreen mode

And then in the main component we have a trigger and UI returned from the hook that we can use:


export const RefinedQuizForm= () => {
   ...
   const { createQuestion, CreateQuestionUI } = useCreateQuestion();

   return (
    <Box component="main">
     ...
      <Container>
          <Stack>
            <Typography >
              Questions
            </Typography>
            <Box>
              <Fab
                ...
                onClick={createQuestion}
              >
                <Add />
                Add
              </Fab>
            </Box>
          </Stack>
         ....
         {CreateQuestionUI}
         ....

      </Container>
    </Box>
  ); 
}
Enter fullscreen mode Exit fullscreen mode

We just got rid of:

  1. Dealing with rendering the form UI
  2. Dealing with UI toggling flags
  3. Dealing with the actual request and its feedback

It's all encapsulated in the hook itself. The monster component eventually slims down to a bunch of hooks doing the CRUD heavy lifting and rendering the list of Questions

const RefinedQuizForm: FC = () => {
  const { query, editQuestion } = useQuestions();

  const { createQuestion, CreateQuestionUI } = useCreateQuestion();

  const { editQuestion: showEditForm, EditQuestionUI } = useEditQuestion();

  const { deleteQuestion, DeleteQuestionUI } = useDeleteQuestion();

  const { showAnswerForm, CreateAnswerUI } = useCreateAnswer();

  const { deleteAnswer, DeleteAnswerUI, answerToDelete } = useDeleteAnswer();

  const {
    moveQuestion,
    FeedbackUI: SaveQuestionsFeedbackUI,
    isUpdatingQuestions,
  } = useMoveQuestion(query.data || []);

  const {
    moveAnswer,
    activeQuestion,
    FeedbackUI: MoveAnswerFeedbackUI,
  } = useMoveAnswer();

  return (
    <Box component="main">
      ....
      <Container>
          ....
          <Stack gap={2}>
            {questions.map((question, questionIndex) => (
              ...
            ))}
          </Stack>

          {CreateQuestionUI}

          {EditQuestionUI}

          {DeleteQuestionUI}

          {CreateAnswerUI}

          {DeleteAnswerUI}

          {SaveQuestionsFeedbackUI}

          {MoveAnswerFeedbackUI}

      </Container>
    </Box>
  );
};

export default RefinedQuizForm;
Enter fullscreen mode Exit fullscreen mode

Other improvements

Well there's an obvious one that probably wouldn't surprise anyone: create a separate component for each rendered Question in the list to reduce the inline markup we have in the main component.

Extracting list items to new component files is among the things I stopped doing automatically without thinking. It's wiser to first consider the pros and cons, because there is always some of both.

If it's just a few lines of markup it might not be worth it creating a new component, because we have to define Props and pass them + we can't scan the underlying UI at a glance.
In this particular case though the Question markup is getting big and complicated, with a lot of buttons. Each one has its own list inside as well where all the Answers are rendered.
It wouldn't be unrealistic to expect that one day a product person comes along saying we should implement drag and drop, which brings even more overhead.

... which leads us to the next change:

const RefinedQuizForm: FC = () => {
  ...

  return (
    <Box component="main">
      ...
      <Container>
        ...
        {questions.map((question, questionIndex) => (
          <QuestionCard
            key={question.id}
            question={question}
            ...
            onEdit={() => showEditForm(question)}
            onDelete={() => deleteQuestion(question.id)}
            ...
            onMoveQuestion={(direction) =>
              moveQuestion(direction, questionIndex)
            }
            onMoveAnswer={(direction, answerIndex) =>
              moveAnswer(direction, answerIndex, question)
            }
          />
       ))}     
       ...
     </Container>
   </Box>
  );
};

export default RefinedQuizForm;
Enter fullscreen mode Exit fullscreen mode

There are a lot of props being passed there and it certainly doesn't feel perfect, but it's a conscious tradeoff.
One small detail that I like is that callbacks like "onEdit" and "onDelete" are quite blank from the "Question" component's perspective:

// Question.tsx

type Props = {
  question: Question;
  ...
  onEdit: () => void;
  onDelete: () => void;
  ...
};

Enter fullscreen mode Exit fullscreen mode

They don't need to pass back the "question" object as a param, because it's already available in the upper scope, thus relieving the component's API a little.

Final words

The way that noise and entropy gradually built up in a component has been bothering me for a long time, especially the parts where I handle more and more modals, confirmations, snackbars and all the creeping "useState" flags associated with them.

The "hook of all trades" technique has really helped me clean things up and hide that kind of stuff under proper abstractions to the point where I actually like going back to my components even after a few months have passed.

What we used here was a simplified excerpt from my dear personal project of creating tactically based, competitive trivia games where knowing the right answers might not always be enough.

Stay tuned for upcoming posts from the same series where I'll expand that very same codebase with topics like:

  1. How I handle Nest BE and React FE in a monorepo environment and how we can share code and types between them. Why I no more throw Errors and what we can do instead.
  2. A simple and elegant decorator-based authorization system in Nest.
  3. Why I prefer ( if I have to pick just one ) FE integration testing over unit and some API mocking techniques.
  4. Why I don't directly use primitive typescript types like "string" and "number" and what can be done instead to make things stricter. How nice a library like zod plays along with that.

Thanks for coming so far, I hope it was worth the time.

Top comments (0)