How (not) to use translations outside of React components
Apr 21, 2023 · by Jan AmannHave you ever wondered why next-intl
doesn’t provide an API to consume translations outside of React components?
The traditional way to internationalize your app with next-intl
is to use the useTranslations
hook:
import {useTranslations} from 'next-intl';
function About() {
const t = useTranslations('About');
return <h1>{t('title')}</h1>;
}
Why is it not possible to format messages e.g. in utility functions? Is there something missing here?
This may seem like an unnecessary limitation, but the absence of this feature is intentional and aims to encourage the use of proven patterns that avoid potential issues—especially if they are easy to overlook.
Example: Formatting error messages
Let’s assume you have a FeedbackForm
component that posts user feedback to a backend endpoint. Unfortunately, the server occasionally returns a 504 status code due to the high volume of feedback. To improve the user experience, you would like to implement automatic retries and provide appropriate feedback to the user.
Here’s a naive approach. Can you spot an issue with this implementation?
import {useTranslations, useNow} from 'next-intl';
import {addMinutes} from 'date-fns';
function sendFeedback() {
// ❌ Bad implementation: Returns formatted messages
API.sendFeedback().catch((error) => {
// In case of a gateway timeout, notify the
// user that we'll try again in 5 minutes
if (error.status === 504) {
// (let's assume `t` is defined here for the sake of the example)
return t('timeout', {nextAttempt: addMinutes(new Date(), 5)});
}
});
}
function FeedbackForm({user}) {
const t = useTranslations('Form');
const [errorMessage, setErrorMessage] = useState();
function onSubmit() {
sendFeedback().catch((errorMessage) => {
setErrorMessage(errorMessage);
});
}
return (
<form onSubmit={onSubmit}>
{errorMessage != null && <p>{errorMessage}</p>}
...
</form>
);
}
Have you found an issue?
Let’s have a look together:
- The
nextAttempt
value is interpolated into the message in a utility function that is called by an event handler. There’s no way how we can keep the remaining time updated as we’re nearing the retry timeout. - If the user changes the language, the error message will remain in the previously selected language, leading to a jarring user experience.
A better way: Formatting during render
To avoid these issues, we can format messages during the rendering phase of React, turning data structures into human readable strings.
import {useTranslations, useNow} from 'next-intl';
import {addMinutes} from 'date-fns';
function FeedbackForm({user}) {
const t = useTranslations('Form');
const [retry, setRetry] = useState();
const now = useNow({
// Update every minute
updateInterval: 1000 * 60
});
function onSubmit() {
// ✅ Good implementation: Store data structures in state
API.sendFeedback().catch((error) => {
if (error.status === 504) {
setRetry(addMinutes(now, 5));
}
});
}
return (
<form onSubmit={onSubmit}>
{retry != null && <p>{t('timeout', {nextAttempt: nextAttempt - now})}</p>}
...
</form>
);
}
Now, we can offer a better user experience by interactively counting down the time to the next attempt.
Additionally, this approach is more robust to possibly unexpected states, like the user changing the language while the timeout message is being displayed.
The exception that proves the rule
If you’re working with Next.js, you might want to translate i18n messages in API routes, Route Handlers or the Metadata API.
next-intl/server
provides a set of awaitable versions of the functions that you usually call as hooks from within components. These are agnostic from React and can be used for these cases.
import {getTranslations} from 'next-intl/server';
// The `locale` is received from Next.js via `params`
const locale = params.locale;
// This creates the same function that is returned by `useTranslations`.
const t = await getTranslations({locale});
// Result: "Hello world!"
t('hello', {name: 'world'});
This seems familiar
If you’ve been working with React for a longer time, you might have experienced the change from component{DidMount,DidUpdate,WillUnmount}
to useEffect
. The reason why useEffect
is superior is because it nudges the developer into a direction where the app is always in sync and by doing this, a whole array of potential issues just magically disappear.
By limiting ourselves to only format messages during render, we’re in a similar situation: The rendered output of translated messages is always in sync with app state and we can rely on the app being consistent.
Related: “How can I reuse messages?” in the structuring messages docs
Let’s keep in touch: