This is part 1 of the "Building 3via" series where we'll be creating a full stack MERN application for creating tactical quizes.
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>
}
- 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>
}
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
- Some prior knowledge of React and concepts like custom hooks
- Basic knowledge of popular libraries in the React ecosystem like react-query, react-hook-form, MUI
The problem
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>
);
};
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);
- A handler that executes a mutation ( usually - a network request )
const handleAddQuestion = (dto: CreateQuestionDto) => {
addQuestion.mutate(dto, {
onSuccess: () => {
toggleCreateForm(false);
},
});
};
- Some form UI, in our case - presented in a modal:
<Dialog open={visibleCreateForm} fullWidth>
...
<UpsertQuestionForm
isLoading={addQuestion.isLoading}
error={addQuestion.error}
onSubmit={handleAddQuestion}
/>
</Dialog>
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>
),
};
};
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>
);
}
We just got rid of:
- Dealing with rendering the form UI
- Dealing with UI toggling flags
- 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;
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;
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;
...
};
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:
- 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.
- A simple and elegant decorator-based authorization system in Nest.
- Why I prefer ( if I have to pick just one ) FE integration testing over unit and some API mocking techniques.
- 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)