In the last monthly post, I told you that I was going to add Tanstack Query to Timo to clean up the server state management code. I thought that this would reduce the size of the codebase since Tanstack Query handles a lot of the repetitive logic needed for interacting with a REST API.
But when I counted the lines of the code after the refactor, I saw that I had only managed to save 3 lines of code. How could this be?
The mysterious case of no missing lines
The last time you met Timo, it only had a small amount of client state. So to try and increase the amount of client state for the reviews, I decided to add a profile screen.
After implementing the profile screen, this was what cloc had to say about the lines of the code in the codebase.
packages/react
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JSX 6 55 2 549
CSS 5 25 0 145
JSON 1 0 0 22
JavaScript 1 1 1 20
HTML 1 0 0 13
-------------------------------------------------------------------------------
SUM: 14 81 3 749
-------------------------------------------------------------------------------
This was when I started refactoring Timo to use Tanstack Query. Since Timo is small, this only took me around 3 hours. I tried to get Github Copilot to do the refactoring for me but annoyingly, it generated snippets using react-query
which is Tanstack Query's predecessor. So I used Copilot's snippets as a starting point and refactored it manually to use Tanstack Query's newer API.
As I removed one useState
hook after another, I felt like I was getting rid of a lot of code. So when Timo finally worked as it should, I triumphantly ran cloc again.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JSX 6 51 2 546
CSS 5 25 0 145
JSON 1 0 0 23
JavaScript 1 1 1 20
HTML 1 0 0 13
-------------------------------------------------------------------------------
SUM: 14 77 3 747
-------------------------------------------------------------------------------
Oh. Only 3 lines smaller? I ran cloc on both versions again to double check. But they were correct.
How could this be?
Adopting Tanstack Query definitely made the codebase tidier. For example, the code for loading and filtering the time entries went from this ugly thing,
import { listEntries } from '@timo/common/api';
...
const Entries = ({ history }) => {
const formRef = createRef(null);
const [entries, setEntries] = useState(null);
const [statusMessage, setStatusMessage] = useState(null);
const handleListEntriesResponse = (entries) => {
setEntries(entries);
if (entries.length === 0) {
setStatusMessage('No entries found');
} else {
setStatusMessage(null);
}
};
const handleListEntriesError = (error) => {
setStatusMessage(error.message);
};
useEffect(() => {
const formData = new FormData(formRef.current);
const from = formData.get('from');
const to = formData.get('to');
if (entries === null) {
setStatusMessage('Loading...');
listEntries({
from,
to
}).then(handleListEntriesResponse)
.catch(handleListEntriesError);
}
}, [entries]);
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const from = formData.get('from');
const to = formData.get('to');
setStatusMessage('Loading...');
listEntries({ from, to })
.then(handleListEntriesResponse)
.catch(handleListEntriesError);
};
...
}
To this
import { listEntries } from '@timo/common/api';
...
const Entries = ({ history }) => {
const formRef = createRef(null);
const [params, setParams] = useState({
from: getDateString(firstDateOfMonth),
to: getDateString(lastDateOfMonth)
});
const { data: entries, isFetching, isError, error, refetch } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
const statusMessage =
isFetching ? 'Loading...' :
isError ? error.message :
entries?.length === 0 ? 'No entries found' : null;
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(formRef.current);
setParams({
from: formData.get('from'),
to: formData.get('to')
});
};
...
}
Tanstack Query allowed me to get rid of 2 useState
hooks and 1 useEffect
hook. In my experience, the useEffect
hook has been a major source of pain so removing a component from it is definitely a win. But what about the lines?
This is the line diff of refactoring Timo to use Tanstack query.
(Additions) (Deletions) (Filename)
1 0 packages/tanstack-query/package.json
8 3 packages/tanstack-query/src/App.jsx
52 69 packages/tanstack-query/src/routes/Entries/Entries.jsx
23 16 packages/tanstack-query/src/routes/Login/Login.jsx
22 25 packages/tanstack-query/src/routes/NewEntry/NewEntry.jsx
31 30 packages/tanstack-query/src/routes/Profile/Profile.jsx
packages/tanstack-query
started out as a copy of packages/react
which is the plain React implementation of Timo. So this diff shows what was changed on top of packages/react
to adopt Tanstack query. What I didn't expect when thinking about this refactor was that adapting the result of Tanstack Query's useQuery
or useMutation
hooks could be quite awkward. Here are two examples, where the result of useQuery
and useMutation
are used to display a single status message.
const { data: entries, isFetching, isError, error } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
const statusMessage =
isFetching ? 'Loading...' :
isError ? error.message :
entries?.length === 0 ? 'No entries found' : null;
const { mutate: create, error, isPending, isSuccess } = useMutation({
mutationFn: createEntry
});
const statusMessage =
error ? error.message :
isPending ? 'Saving...' :
isSuccess ? 'Time logged successfully' : '';
These ternary statements could be written as a single line. But that would make this ugly, nested ternary even harder to read. A small improvement to this code is to use the status
property with a lookup object.
const { mutate: create, error, status } = useMutation({
mutationFn: createEntry
});
const STATUS_MESSAGES = {
['idle']: ''
['error']: error.message,
['pending']: 'Saving...',
['success']: 'Time logged successfully'
}
const statusMessage = STATUS_MESSAGES[status]
But it still becomes awkward when the message has to be calculated based on the state of the returned data.
const { data: entries, error } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
const STATUS_MESSAGES = {
['pending']: 'Loading...'
['error']: error.message
['success']: entries?.length === 0 ? 'No entries found' : null
}
const statusMessage = STATUS_MESSAGES[status]
There might be other ways to express this logic. But I think that this logic will always be a bit awkward and the only way to avoid it is to change the behavior of the app.
Takeaways
When I started this refactor, I wanted to write a post at the end about how I was able to reduce the size of the codebase. But that didn't happen. So here are some other takeaways from this situation.
Hypotheses are not real. Measure to be sure.
It it easy to be convinced that a certain practice will have a certain positive or negative effect. But until it is implemented and measured, it can't be known for certain.
Metrics can lead you astray
If I was only concerned with reducing the lines of the code, then adopting Tanstack query has little to no benefits (at least on a very small app like Timo). But that is not the bigger picture.
The real benefit of Tanstack query is that it introduces a good pattern for interacting with a REST API. It also reduces the number of side effects that has to be managed manually. I think both of these benefits far outweigh the metric of reducing lines of code. But if I was only focused on the lines of code metric, I might have missed these benefits.
Coming up next
With all this prep work out of the way, I think we are finally ready to start taking a closer look at some state management libraries. In the next monthly post, I will dive into Zustand which has become a favorite in the community.