Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: memoizable react translations #1721

Merged
merged 3 commits into from
Jul 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/react/src/I18nProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, render } from "@testing-library/react"

import { I18nProvider, useLingui } from "./I18nProvider"
import { setupI18n } from "@lingui/core"
import { useMemo } from "react"

describe("I18nProvider", () => {
it(
Expand Down Expand Up @@ -188,4 +189,39 @@ describe("I18nProvider", () => {
)
expect(getByTestId("child")).toBeTruthy()
})

it("using the _ function from useLingui renders fresh translations even when memoized", () => {
const greetingId = "greeting"
const i18n = setupI18n({
locale: "en",
messages: {
en: {
[greetingId]: "Hello World",
},
cs: {
[greetingId]: "Ahoj světe",
},
},
})

const ComponentWithMemo = () => {
const { _ } = useLingui()
const message = useMemo(() => _(greetingId), [_])
return <div>{message}</div>
}

const { getByText } = render(
<I18nProvider i18n={i18n}>
<ComponentWithMemo />
</I18nProvider>
)

expect(getByText("Hello World")).toBeTruthy()

act(() => {
i18n.activate("cs")
})

expect(getByText("Ahoj světe")).toBeTruthy()
})
})
4 changes: 3 additions & 1 deletion packages/react/src/I18nProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { TransRenderProps } from "./Trans"

export type I18nContext = {
i18n: I18n
_: I18n["_"]
defaultComponent?: ComponentType<TransRenderProps>
}

export type I18nProviderProps = I18nContext & {
export type I18nProviderProps = Omit<I18nContext, "_"> & {
children?: React.ReactNode
}

Expand Down Expand Up @@ -46,6 +47,7 @@ export const I18nProvider: FunctionComponent<I18nProviderProps> = ({
() => ({
i18n,
defaultComponent,
_: i18n.t.bind(i18n),
}),
[i18n, defaultComponent]
)
Expand Down
36 changes: 22 additions & 14 deletions website/docs/ref/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ Components from `@lingui/react` wrap the vanilla JS API from `@lingui/core`. Rea

All i18n components render translation as text without a wrapping tag. This can be customized in two different ways:

- globally: using `defaultComponent` prop on [`I18nProvider`](#i18nprovider) component
- locally: using `render` prop or `component` on i18n components
- globally: using `defaultComponent` prop on [`I18nProvider`](#i18nprovider) component
- locally: using `render` prop or `component` on i18n components

### Global Configuration

Default rendering component can be set using `defaultComponent` prop in [`I18nProvider`](#i18nprovider). The main use case for this is rendering translations in `<Text>` component in React Native.

### Local Configuration

| Prop name | Type | Description |
|-------------| ----------------------------------------- |------------------------------------------------|
| `className` | string | Class name to be added to `<span>` element |
| `render` | Function(props) -> Element \| `null` | Custom render callback to render translation |
| `component` | Component \| `null` | Custom component to render translation |
| `comment` | string | Comment picked up by extractor to provide translation context |
| Prop name | Type | Description |
| ----------- | ------------------------------------ | ------------------------------------------------------------- |
| `className` | string | Class name to be added to `<span>` element |
| `render` | Function(props) -> Element \| `null` | Custom render callback to render translation |
| `component` | Component \| `null` | Custom component to render translation |
| `comment` | string | Comment picked up by extractor to provide translation context |

`className` is used only for built-in components (when *render* is string).

Expand Down Expand Up @@ -77,10 +77,10 @@ Lingui context object is exported from the package (`import { LinguiContext } fr
`I18nProvider` renders its children only after a locale is activated. This ensures that the components consuming `i18n` have access to the translations.
Additionally, it subscribes to change events emitted by the `i18n` object and re-renders all components consuming the Lingui context when messages are updated or when a new locale is activated.

| Prop name | Type | Description |
|--------------------|-----------------------|---------------------------------------------------------------------------|
| `i18n` | `I18n` | The i18n instance (usually the one imported from `@lingui/core`) |
| `children` | `React.ReactNode` | React Children node |
| Prop name | Type | Description |
| ------------------ | --------------------- | ------------------------------------------------------------------------------ |
| `i18n` | `I18n` | The i18n instance (usually the one imported from `@lingui/core`) |
| `children` | `React.ReactNode` | React Children node |
| `defaultComponent` | `React.ComponentType` | A React component within which translation strings will be rendered (optional) |

`defaultComponent` has the same meaning as `component` in other i18n components. [`Rendering of translations`](#rendering-translations) is explained at the beginning of this document.
Expand Down Expand Up @@ -113,9 +113,17 @@ const App = () => {

### useLingui

This hook allows access to the Lingui context. It returns an object with the same values that were passed to the `I18nProvider` component.
This hook allows access to the Lingui context. It returns an object with the following content:

Components that use `useLingui` hook will re-render when locale and / or catalogs change, ensuring that the translations are always up-to-date.
| Key | Type | Description |
| ------------------ | --------------------- | ---------------------------------------------------------------------- |
| `i18n` | `I18n` | the `I18` object instance that you passed to `I18nProvider` |
| `_` | `I18n[_]` | reference to the [`i18n._`](/ref/core#i18n._) function, explained below |
| `defaultComponent` | `React.ComponentType` | the same `defaultComponent` you passed to `I18nProvider`, if provided |

Components that use `useLingui` hook will re-render when locale and / or catalogs change. However, the reference to the `i18n` object is stable and doesn't change between re-renders. This can lead to unexpected behavior with memoization (see [memoization pitfall](/tutorials/react-patterns#memoization-pitfall)).

To alleviate the issue, `useLingui` provides the `_` function, which is the same as [`i18n._`](/ref/core#i18n._) but *its reference changes* with each update of the Lingui context. Thanks to that, you can safely use this `_` function as a hook dependency.

```jsx
import React from "react"
Expand Down
17 changes: 14 additions & 3 deletions website/docs/tutorials/react-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,11 @@ export default function StatusDisplay({ statusCode }) {

## Memoization pitfall

In the following contrived example, we document how welcome message will or will not be updated when locale changes.
In the following contrived example, we document how a welcome message will or will not be updated when locale changes.

The documented behavior is expected, because of how `useMemo` dependencies work. In order for translations to update, the `useMemo` needs to depend on the i18n context.
The documented behavior may not be intuitive at first, but it is expected, because of how `useMemo` dependencies work.

We acknowledge that this is not intuitive, and we're open to accepting a solution to make this easier.
To avoid bugs with stale translations, use the `_` function returned from [`useLingui`](/ref/react#uselingui): it is safe to use with memoization because its reference changes whenever the Lingui context updates. We are open to accepting solutions to make working with the Lingui context easier.

Please also note that `useMemo` is meant as a performance optimization in React and you probably don't need to memoize your translations. Additionally, this issue is not present when using the `Trans` component which we recommend to use when possible.

Expand Down Expand Up @@ -345,4 +345,15 @@ export function Welcome() {

return <div>{welcome}</div>;
}

// 🤩 Better! `useMemo` consumes the `_` function from the Lingui context
export function Welcome() {
const { _ } = useLingui();

const welcome = useMemo(() => {
return _(welcomeMessage);
}, [_]);

return <div>{welcome}</div>;
}
```