Create custom i18n provider with effector and typescript

Today I will show you how to create your own i18n solution with effector, typescript and react.

codesandbox https://codesandbox.io/s/react-i18n-with-effector-and-ts-gtet4

First of all, we should design the state of our provider, consisting of two parts:

  • current language
  • translations

Ok, let's do it. Create a file store.ts and write the following code:

/*
* state example
*
* {
* en: {
* 'hello': 'world'
* },
* ru: {
* 'привет': 'мир'
* }
* }
*
*/
type Translates = Dictionary<Dictionary<string, string>, string>; // type from ts-essentials
export const $language = createStore<string>('en');
export const $translates = createStore<Translates>({});
export const $i18n = createStoreObject({language: $language, translates: $translates});

Ok, we created the state for our i18n library. This state will store information about the current language and a set of translations. Now we should provide the language switching at runtime and dynamically adding the sets of the translates.

I created two events in the file events.ts:

export type AddTranslates = { language: string, translates: Dictionary<string, string> };
export const addTranslates = createEvent<AddTranslates>('@@i18n/translates/add');
export const setLanguage = createEvent<string>('@@i18n/language/set');

Thereafter we should subscribe our stores to this events

// on.ts
$translates.on(
addTranslates,
(state, payload) => {
return {
...state,
[payload.language]: {
...state[payload.language],
...payload.translates
}
};
});
$language.on(
setLanguage,
(_, payload) => {
return payload.
});

Now we can switch the language in our application with the event call:

setLanguage('ru'); // set ru culture
setLanguage('en'); // set en culture
setLanguage('es'); // set es culture

Now, when the basis of our app is almost finished, let's create React-Component, that allow us to use translations. We'll create the "translate.tsx" file.

type TranslateProps = {
id: string,
children?: ((props: string) => React.ReactNode) | string,
};
const _getTranslate = (
id: string,
value: string | undefined | null,
children: string | undefined
) => {
if (value) {
return value;
}
if (children) {
return children;
}
return `{{${id}}}`;
};
export type StoreType = {
language: string;
translates: Dictionary<Dictionary<string, string>>;
};
export const Translate =
createComponent<TranslateProps, StoreType>(
$i18n,
(props, state) => {
const { children, id } = props;
const { translates, language } = state;
const translate = translates[language][id];
const value = _getTranslate(id, translate, typeof children !== 'function' ? children : undefined);
return typeof children === 'function'
? children(value)
: value;
});

As you can see, creating such a thing didn't take long. The benefits is that now we can subscribe to our localisation store and change translations even where it couldn't be done in the usual way. For example, we can create a helper class that allow us easy to interact with localisation in the app:

// userCulture.ts
class UserCulture {
private _userCulture: string | null;
private _translations: Dictionary<string, string>;
public set = (lang: string) => this._userCulture = lang;
public setTranslations = (value: Dictionary<string, string>) => this._translations = value;
public getCurrentCulture = (): string => {
const culture = this._userCulture;
if (!!culture) {
return culture;
}
throw new Error('culture is undefined');
}
public localize = (data: any) => {
if (!!data.ru && this._isCulture('ru')) {
return data.ru;
}
if (!!data.en && this._isCulture('en')) {
return data.en;
}
if (!!data.de && this._isCulture('de')) {
return data.de;
}
return data.en;
}
public translate = (key: string) => {
if(!!this._translations) {
if(!!key) {
return this._translations[key];
}
throw new Error('invalid resource key');
}
throw new Error('translations must be a set');
}
private _isCulture = (culture: string) => {
if (!!this._userCulture) {
return this._userCulture.toLowerCase() === culture;
}
throw new Error('culture must be a set');
}
}
export const { localize, set, getCurrentCulture, translate, setTranslations } = new UserCulture();
// store.ts
import {set, setTranslations} from './userCulture';
// !!side-effect!! set app culture on each store update
$language.watch((lang) => set(lang));
// !!side-effect!! update the translations set in our culture manager on each culture switching
$i18n.watch(state => {
setTranslations(state.translates[state.language]);
});

Then we can define the translate function and call it from anywhere in our code.

// translate.tsx, put this code after Translate component
// import {translate} from './userCulture';
export const getTranslate = (path: string): string => {
return _getTranslate(path, translate(path), path);
};

Here's example of using:

// api.ts
import {getCurrentCulture} from './userCulture';
const url = `https://some.url/?age=20&culture=${getCurrentCulture()}`;
// store2.ts
// en
// {value: 1, name: 'week'}
// {value: 10, name: 'weeks'}
//
// ru
// {value: 1, name: 'неделя'}
// {value: 10, name: 'недель'}
export const weeks = (n: number, lang: string) => {
return pluralize(n, "неделя", "недели", "недель", "week", "weeks")[lang];
};
const numbers = Array.from({ length: 10 }).map((_, item) => item + 1);
export const $weeks = $language.map(lang => {
return numbers.map(value => {
return {
value: value,
name: weeks(value, lang)
};
});
});

And at the end i will show some simple, but very useful components

// CultureRenderer
type CultureRendererProps = {
children: ((props: string) => React.ReactNode),
cultures: Array<string>
};
export type Store = {
language: string;
translates: Dictionary<Dictionary<string, string>>;
};
export const CultureRenderer =
createComponent<CultureRendererProps, Store>(
$i18n,
(props, state) => {
const langInLower = state.language.toLowerCase();
const hasCulture = props.cultures.some((c) => {
if (!!c && c.toLowerCase() === langInLower) {
return true;
}
return false;
})
return hasCulture
? props.children(langInLower)
: null;
});
// usage
<CultureRenderer cultures={["ru"]}>
{culture => <h4>info for {culture} culture</h4>}
</CultureRenderer>
<CultureRenderer cultures={["en"]}>
{culture => <h4>info for {culture} culture</h4>}
</CultureRenderer>
<CultureRenderer cultures={["en", "ru"]}>
{culture => <h4>info for all cultures, current = {culture}</h4>}
</CultureRenderer>

Thank you for reading!

Media queries with effector

Hi!

In this article I will show how to make react component which will work like this

const Button = () => (
<>
<Screen landscape large>
[large wide button]
</Screen>
<Screen portrait small medium>
[compact button]
</Screen>
</>
)

Media queries itself could been handled in such way:

const mediaQueryList = window.matchMedia("(orientation: portrait)")
mediaQueryList.addListener(e => {
if (e.matches) {
// The viewport is currently in portrait orientation
} else {
// The viewport is currently in landscape orientation
}
})

But how this could been used in react components? (actually, we’ll make more universal thing, which can be used in various ways)

Effector can reacts on media queries changes and provide current query state as store

import {createEvent, createStore} from 'effector'
const orientationChange = createEvent('orientation changed')
const isPortrait = createStore(false)
.on(orientationChange, (state, e) => e.matches)
const orientationMediaQuery = window.matchMedia('(orientation: portrait)')
orientationMediaQuery.addListener(orientationChange)

orientationChange is just a function

We can rewrite it for reuse with any query

import {createEvent, createStore} from 'effector'
export function mediaMatcher(query) {
const queryChange = createEvent('query change')
const mediaQueryList = window.matchMedia(query)
mediaQueryList.addListener(queryChange)
const isQueryMatches = createStore(mediaQueryList.matches)
.on(queryChange, (state, e) => e.matches)
return isQueryMatches
}
/* declaring queries */
const small = mediaMatcher('(max-width: 768px)')
const medium = mediaMatcher('(min-width: 769px) and (max-width: 1024px)')
const large = mediaMatcher('(min-width: 1025px)')
const portrait = mediaMatcher('(orientation: portrait)')
/* using queries */
small.watch(isSmall => {
console.log('is small screen?', isSmall)
})

For my device currently it prints is small screen? false

Lets make it single store, thereby creating common base to connect it to view framework further

//mediaMatcher.js
import {createEvent, createStore} from 'effector'
export function mediaMatcher(query) {
const queryChange = createEvent('query change')
const mediaQueryList = window.matchMedia(query)
mediaQueryList.addListener(queryChange)
const isQueryMatches = createStore(mediaQueryList.matches)
.on(queryChange, (state, e) => e.matches)
return isQueryMatches
}
import {createStoreObject} from 'effector'
import {mediaMatcher} from './mediaMatcher'
/* declaring queries and merge them into single store*/
export const screenQueries = createStoreObject({
small: mediaMatcher('(max-width: 768px)'),
medium: mediaMatcher('(min-width: 769px) and (max-width: 1024px)'),
large: mediaMatcher('(min-width: 1025px)'),
portrait: mediaMatcher('(orientation: portrait)'),
})
/* using queries */
screenQueries.watch(queries => {
const {
small,
medium,
large,
portrait,
} = queries
console.log(`
is small ${small}
is medium ${medium}
is large ${large}
is portrait ${portrait}
is landscape ${!portrait}
`)
})

Now we could connect it to view framework, react, using effector-react library

//mediaMatcher.js
import {createEvent, createStore} from 'effector'
export function mediaMatcher(query) {
const queryChange = createEvent('query change')
const mediaQueryList = window.matchMedia(query)
mediaQueryList.addListener(queryChange)
const isQueryMatches = createStore(mediaQueryList.matches)
.on(queryChange, (state, e) => e.matches)
return isQueryMatches
}
//screenQueries.js
import {createStoreObject} from 'effector'
import {mediaMatcher} from './mediaMatcher'
/* declaring queries and merge them into single store*/
export const screenQueries = createStoreObject({
small: mediaMatcher('(max-width: 768px)'),
medium: mediaMatcher('(min-width: 769px) and (max-width: 1024px)'),
large: mediaMatcher('(min-width: 1025px)'),
portrait: mediaMatcher('(orientation: portrait)'),
})
import {createComponent} from 'effector-react'
import {screenQueries} from './screenQueries'
function orientationCheck(props, queries) {
//if there no constraint on orientation
if (!props.portrait && !props.landscape) return true
return (
(props.portrait && queries.portrait) ||
(props.landscape && !queries.portrait)
)
}
function screenSizeCheck(props, queries) {
//if there no constraint on screen size
if (!props.small && !props.medium && !props.large) return true
return (
(props.small && queries.small) ||
(props.medium && queries.medium) ||
(props.large && queries.large)
)
}
export const Screen = createComponent(
screenQueries, (props, queries) => {
const orientationAllowed = orientationCheck(props, queries)
const screenSizeAllowed = screenSizeCheck(props, queries)
if (orientationAllowed && screenSizeAllowed) {
return props.children
}
return null
}
)
Screen.defaultProps = {
children: null,
small: false,
medium: false,
large: false,
portrait: false,
landscape: false,
}

Done!

It support nesting out from a box

export const AppLogo = ({brandName, fullLogo, squareLogo}) => (
<>
<Screen landscape>
<img src={fullLogo}/>
<Screen large>
{brandName}
</Screen>
</Screen>
<Screen portrait>
<img src={squareLogo}/>
</Screen>
</>
)

More advanced nesting example see here