A RetroSearch Logo

Home - News ( United States | United Kingdom | Italy | Germany ) - Football scores

Search Query:

Showing content from https://effector.dev/ru/typescript/usage-with-effector-react below:

Website Navigation


Использование с пакетом effector-react | effector

[ en ]

TypeScript - это типизированное расширение JavaScript. Он стал популярным в последнее время благодаря преимуществам, которые он может принести. Если вы новичок в TypeScript, рекомендуется сначала ознакомиться с ним, прежде чем продолжить. Вы можете ознакомиться с документацей здесь.

Какие преимущества Typescript может принести вашему приложению:

  1. Безопасность типов для состояний, сторов и событий
  2. Простой рефакторинг типизированного кода
  3. Превосходный опыт разработчика в командной среде

Практический пример

Мы пройдемся по упрощенному приложению чата, чтобы продемонстрировать возможный подход к включению статической типизации. Это приложение для чата будет иметь API-модель, которая загружает и сохраняет данные из локального хранилища localStorage.

Полный исходный код можно посмотреть на github. Обратите внимание, что, следуя этому примеру самостоятельно, вы ощутите пользу от использования TypeScript.

Давайте создадим API-модель

Здесь будет использоваться структура каталогов на основе методологии feature-sliced.

Давайте определим простой тип, который наша импровизированная API будет возвращать.

interface Author {

id: string;

name: string;

}

export interface Message {

id: string;

author: Author;

text: string;

timestamp: number;

}

Наша API будет загружать и сохранять данные в localStorage, и нам нужны некоторые функции для загрузки данных:

const LocalStorageKey = "effector-example-history";

function loadHistory(): Message[] | void {

const source = localStorage.getItem(LocalStorageKey);

if (source) {

return JSON.parse(source);

}

return undefined;

}

function saveHistory(messages: Message[]) {

localStorage.setItem(LocalStorageKey, JSON.stringify(messages));

}

Также нам надо создать несколько библиотек для генерации идентификатров и ожидания для имитации сетевых запросов.

export const createOid = () =>

((new Date().getTime() / 1000) | 0).toString(16) +

"xxxxxxxxxxxxxxxx".replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16)).toLowerCase();

export function wait(timeout = Math.random() * 1500) {

return new Promise((resolve) => setTimeout(resolve, timeout));

}

Отлично! Теперь мы можем создать эффекты, которые будут загружать сообщения.

// Здесь эффект определен со статическими типами. Void определяет отсутствие аргументов.

// Второй аргумент в типе определяет тип успешного результата.

// Третий аргумент является необязательным и определяет тип неудачного результата.

export const messagesLoadFx = createEffect<void, Message[], Error>(async () => {

const history = loadHistory();

await wait();

return history ?? [];

});

interface SendMessage {

text: string;

author: Author;

}

// Но мы можем использовать вывод типов и задавать типы аргументов в определении обработчика.

// Наведите курсор на `messagesLoadFx`, чтобы увидеть выведенные типы:

// `Effect<{ text: string; authorId: string; authorName: string }, void, Error>`

export const messageSendFx = createEffect(async ({ text, author }: SendMessage) => {

const message: Message = {

id: createOid(),

author,

timestamp: Date.now(),

text,

};

const history = await messagesLoadFx();

saveHistory([...history, message]);

await wait();

});

// Пожалуйста, обратите внимание, что мы будем использовать `wait()` для `messagesLoadFx` и `wait()` в текущем эффекте

// Также, обратите внимание, что `saveHistory` и `loadHistory` могут выбрасывать исключения,

// в этом случае эффект вызовет событие `messageDeleteFx.fail`.

export const messageDeleteFx = createEffect(async (message: Message) => {

const history = await messagesLoadFx();

const updated = history.filter((found) => found.id !== message.id);

await wait();

saveHistory(updated);

});

Отлично, теперь мы закончили с сообщениями, давайте создадим эффекты для управления сессией пользователя.

На самом деле я предпочитаю начинать написание кода с реализации интерфейсов:

// Это называется сессией, потому что описывает текущую сессию пользователя, а не Пользователя в целом.

export interface Session {

id: string;

name: string;

}

Кроме того, чтобы генерировать уникальные имена пользователей и не требовать от них ввода вручную, импортируйте unique-names-generator:

import { uniqueNamesGenerator, Config, starWars } from "unique-names-generator";

const nameGenerator: Config = { dictionaries: [starWars] };

const createName = () => uniqueNamesGenerator(nameGenerator);

Создадим эффекты для управления сессией:

const LocalStorageKey = "effector-example-session";

// Обратите внимание, что в этом случае требуется явное определение типов, поскольку `JSON.parse()` возвращает `any`

export const sessionLoadFx = createEffect<void, Session | null>(async () => {

const source = localStorage.getItem(LocalStorageKey);

await wait();

if (!source) {

return null;

}

return JSON.parse(source);

});

// По умолчанияю, если нет аргументов, не предоставлены явные аргументы типа и нет оператора `return`,

// эффект будет иметь тип: `Effect<void, void, Error>`

export const sessionDeleteFx = createEffect(async () => {

localStorage.removeItem(LocalStorageKey);

await wait();

});

// Взгляните на тип переменной `sessionCreateFx`.

// Там будет `Effect<void, Session, Error>` потому что TypeScript может вывести тип из переменной `session`

export const sessionCreateFx = createEffect(async () => {

// Я явно установил тип для следующей переменной, это позволит TypeScript помочь мне

// Если я забуду установить свойство, то я увижу ошибку в месте определения

// Это также позволяет IDE автоматически дополнять и завершать имена свойств

const session: Session = {

id: createOid(),

name: createName(),

};

localStorage.setItem(LocalStorageKey, JSON.stringify(session));

return session;

});

Как нам нужно импортировать эти эффекты?

Я настоятельно рекомендую писать короткие импорты и использовать реэкспорты. Это позволяет безопасно рефакторить структуру кода внутри shared/api и тех же слайсов, и не беспокоиться о рефакторинге других импортов и ненужных изменениях в истории git.

export * as messageApi from "./message";

export * as sessionApi from "./session";

// Types reexports made just for convenience

export type { Message } from "./message";

export type { Session } from "./session";

Создадим страницу с логикой

Типичная структура страниц:

src/

pages/

<page-name>/

page.tsx — только View-слой (представление)

model.ts — код бизнес-логики (модель)

index.ts — реэкспорт, иногда здесь может быть связующий код

Я рекомендую писать код в слое представления сверху вниз, более общий код - сверху. Моделируем наш слой представления. На странице у нас будет два основных раздела: история сообщений и форма сообщения.

export function ChatPage() {

return (

<div className="parent">

<ChatHistory />

<MessageForm />

</div>

);

}

function ChatHistory() {

return (

<div className="chat-history">

<div>Тут будет список сообщений</div>

</div>

);

}

function MessageForm() {

return (

<div className="message-form">

<div>Тут будет форма сообщения</div>

</div>

);

}

Отлично. Теперь мы знаем, какую структуру мы имеем, и мы можем начать моделировать процессы бизнес-логики. Слой представления должен выполнять две задачи: отображать данные из хранилищ и сообщать события модели. Слой представления не знает, как загружаются данные, как их следует преобразовывать и отправлять обратно.

import { createEvent, createStore } from "effector";

// События просто сообщают о том, что что-то произошло

export const messageDeleteClicked = createEvent<Message>();

export const messageSendClicked = createEvent();

export const messageEnterPressed = createEvent();

export const messageTextChanged = createEvent<string>();

export const loginClicked = createEvent();

export const logoutClicked = createEvent();

// В данный момент есть только сырые данные без каких-либо знаний о том, как их загрузить.

export const $loggedIn = createStore<boolean>(false);

export const $userName = createStore("");

export const $messages = createStore<Message[]>([]);

export const $messageText = createStore("");

// Страница НЕ должна знать, откуда пришли данные.

// Поэтому мы просто реэкспортируем их.

// Мы можем переписать этот код с использованием `combine` или оставить независимые хранилища,

// страница НЕ должна меняться, просто потому что мы изменили реализацию

export const $messageDeleting = messageApi.messageDeleteFx.pending;

export const $messageSending = messageApi.messageSendFx.pending;

Теперь мы можем реализовать компоненты.

import { useList, useUnit } from "effector-react";

import * as model from "./model";

// export function ChatPage { ... }

function ChatHistory() {

const [messageDeleting, onMessageDelete] = useUnit([

model.$messageDeleting,

model.messageDeleteClicked,

]);

// Хук `useList` позволяет React не перерендерить сообщения, которые действительно не изменились.

const messages = useList(model.$messages, (message) => (

<div className="message-item" key={message.timestamp}>

<h3>From: {message.author.name}</h3>

<p>{message.text}</p>

<button onClick={() => onMessageDelete(message)} disabled={messageDeleting}>

{messageDeleting ? "Deleting" : "Delete"}

</button>

</div>

));

// Здесь не нужен `useCallback` потому что мы передаем функцию в HTML-элемент, а не в кастомный компонент

return <div className="chat-history">{messages}</div>;

}

Я разделил MessageForm на разные компоненты, чтобы упростить код:

function MessageForm() {

const isLogged = useUnit(model.$loggedIn);

return isLogged ? <SendMessage /> : <LoginForm />;

}

function SendMessage() {

const [userName, messageText, messageSending] = useUnit([

model.$userName,

model.$messageText,

model.$messageSending,

]);

const [handleLogout, handleTextChange, handleEnterPress, handleSendClick] = useUnit([

model.logoutClicked,

model.messageTextChanged,

model.messageEnterPressed,

model.messageSendClicked,

]);

const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {

if (event.key === "Enter") {

handleEnterPress();

}

};

return (

<div className="message-form">

<h3>{userName}</h3>

<input

value={messageText}

onChange={(event) => handleTextChange(event.target.value)}

onKeyPress={handleKeyPress}

className="chat-input"

placeholder="Type a message..."

/>

<button onClick={() => handleSendClick()} disabled={messageSending}>

{messageSending ? "Sending..." : "Send"}

</button>

<button onClick={() => handleLogout()}>Log out</button>

</div>

);

}

function LoginForm() {

const handleLogin = useUnit(model.loginClicked);

return (

<div className="message-form">

<div>Please, log in to be able to send messages</div>

<button onClick={() => handleLogin()}>Login as a random user</button>

</div>

);

}

Управляем сессией пользователя как Про

Создадим сущность сессии. Сущность (entity) - это бизнес-юнит.

import { Session } from "shared/api";

import { createStore } from "effector";

// Сущность просто хранит сессию и некоторую внутреннюю информацию о ней

export const $session = createStore<Session | null>(null);

// Когда стор `$session` обновляется, то стор `$isLogged` тоже будет обновлен

// Они синхронизированы. Производный стор зависит от данных из исходного

export const $isLogged = $session.map((session) => session !== null);

Теперь мы можем реализовать функции входа в систему или выхода на странице. Почему не здесь? Если мы разместим логику входа здесь, у нас будет очень неявная ситуация, когда вы вызываете sessionCreateFx вы не увидите код, который вызывается после эффекта. Но последствия будут видны в DevTools и поведении приложения.

Попробуйте написать код таким очевидным способом в одном файле, чтобы вы и любой член команды могли отследить последовательность выполнения.

Реализуем логику

Отлично. Теперь мы можем загрузить сеанс пользователя и список сообщений на странице. Но у нас нет никакого события, когда мы можем начать это делать. Давайте исправим это.

Вы можете использовать Gate, но я предпочитаю использовать явные события.

// Просто добавьте новое событие

export const pageMounted = createEvent();

Просто добавте useEffect и вызовите связанное событие внутри.

export function ChatPage() {

const handlePageMount = useUnit(model.pageMounted);

React.useEffect(() => {

handlePageMount();

}, [handlePageMount]);

return (

<div className="parent">

<ChatHistory />

<MessageForm />

</div>

);

}

Примечание: если вы не планируете писать тесты для кода эффектора и/или реализовывать SSR, вы можете опустить любое использование useEvent.

В данный момент мы можем загрузить сеанс и список сообщений.

Просто добавьте реакцию на событие, и любой другой код должен быть написан в хронологическом порядке после каждого события:

// Не забудьте про import { sample } from "effector"

import { Message, messageApi, sessionApi } from "shared/api";

import { $session } from "entities/session";

// export stores

// export events

// Здесь место для логики

// Вы можете прочитать этот код так:

// При загрузке страницы, одновременно вызываются загрузка сообщений и сессия пользователя

sample({

clock: pageMounted,

target: [messageApi.messagesLoadFx, sessionApi.sessionLoadFx],

});

После этого нужно определить реакции на messagesLoadFx.done и messagesLoadFx.fail, а также то же самое для sessionLoadFx.

// `.doneData` это сокращение для `.done`, поскольку `.done` returns `{ params, result }`

// Постарайтесь не называть свои аргументы как `state` или `payload`

// Используйте явные имена для содержимого

$messages.on(messageApi.messagesLoadFx.doneData, (_, messages) => messages);

$session.on(sessionApi.sessionLoadFx.doneData, (_, session) => session);

Отлично. Сессия и сообщения получены. Давайте позволим пользователям войти.

// Когда пользователь нажимает кнопку входа, нам нужно создать новую сессию

sample({

clock: loginClicked,

target: sessionApi.sessionCreateFx,

});

// Когда сессия создана, просто положите его в хранилище сессий

sample({

clock: sessionApi.sessionCreateFx.doneData,

target: $session,

});

// Если создание сессии не удалось, просто сбросьте сессию

sample({

clock: sessionApi.sessionCreateFx.fail,

fn: () => null,

target: $session,

});

Давайте реализуем процесс выхода:

// Когда пользователь нажал на кнопку выхода, нам нужно сбросить сессию и очистить наше хранилище

sample({

clock: logoutClicked,

target: sessionApi.sessionDeleteFx,

});

// В любом случае, успешно или нет, нам нужно сбросить хранилище сессий

sample({

clock: sessionApi.sessionDeleteFx.finally,

fn: () => null,

target: $session,

});

Примечание: большинство комментариев написано только для образовательных целей. В реальной жизни код приложения будет самодокументируемым

Но если мы запустим dev-сервер и попытаемся войти в систему, то мы ничего не увидим. Это связано с тем, что мы создали стор $loggedIn в модели, но не изменяем его. Давайте исправим:

import { $isLogged, $session } from "entities/session";

// В данный момент есть только сырые данные без каких-либо знаний о том, как их загрузить

export const $loggedIn = $isLogged;

export const $userName = $session.map((session) => session?.name ?? "");

Здесь мы просто реэкспортировали наш собственный стор из сущности сессии, но слой представления не меняется. Такая же ситуация и со стором $userName. Просто перезагрузите страницу, и вы увидите, что сессия загружена правильно.

Отправка сообщений

Теперь мы можем войти в систему и выйти из нее. Думаю, что вы захотите отправить сообщение. Это довольно просто:

$messageText.on(messageTextChanged, (_, text) => text);

// У нас есть два разных события для отправки сообщения

// Пусть событие `messageSend` реагирует на любое из них

const messageSend = merge([messageEnterPressed, messageSendClicked]);

// Нам нужно взять текст сообщения и информацию об авторе, а затем отправить ее в эффект

sample({

clock: messageSend,

source: { author: $session, text: $messageText },

target: messageApi.messageSendFx,

});

Но если в файле tsconfig.json вы установите "strictNullChecks": true, вы получите ошибку. Это связано с тем, что стор $session содержит Session | null, а messageSendFx хочет Author в аргументах. Author и Session совместимы, но не должны быть null.

Чтобы исправить странное поведение, нам нужно использовать filter:

sample({

clock: messageSend,

source: { author: $session, text: $messageText },

filter: (form): form is { author: Session; text: string } => {

return form.author !== null;

},

target: messageApi.messageSendFx,

});

Я хочу обратить ваше внимание на тип возвращаемого значения form is {author: Session; text: string}. Эта функция называется type guard и позволяет TypeScript сузить тип Session | null до более конкретного Session через условие внутри функции.

Теперь мы можем прочитать это так: когда сообщение должно быть отправлено, возьмите сессию и текст сообщения, проверьте, существует ли сессия, и отправьте его.

Отлично. Теперь мы можем отправить новое сообщение на сервер. Но если мы не вызовем messagesLoadFx снова, мы не увидим никаких изменений, потому что стор $messages не обновился. Мы можем написать универсальный код для этого случая. Самый простой способ - вернуть отправленное сообщение из эффекта.

export const messageSendFx = createEffect(async ({ text, author }: SendMessage) => {

const message: Message = {

id: createOid(),

author,

timestamp: Date.now(),

text,

};

const history = await messagesLoadFx();

await wait();

saveHistory([...history, message]);

return message;

});

Теперь мы можем просто добавить сообщение в конец списка:

$messages.on(messageApi.messageSendFx.doneData, (messages, newMessage) => [

...messages,

newMessage,

]);

Но в данный момент отправленное сообщение все еще остается в поле ввода.

$messageText.on(messageSendFx, () => "");

// Если отправка сообщения не удалась, просто восстановите сообщение

sample({

clock: messageSendFx.fail,

fn: ({ params }) => params.text,

target: $messageText,

});

Удаление сообщения

Это довольно просто.

sample({

clock: messageDeleteClicked,

target: messageApi.messageDeleteFx,

});

$messages.on(messageApi.messageDeleteFx.done, (messages, { params: toDelete }) =>

messages.filter((message) => message.id !== toDelete.id),

);

Но вы можете заметить ошибку, когда состояние “Deleting” не отклчено. Это связано с тем, что useList кэширует рендеры, и не знает о зависимости от состояния messageDeleting. Чтобы исправить это, нам нужно предоставить keys:

const messages = useList(model.$messages, {

keys: [messageDeleting],

fn: (message) => (

<div className="message-item" key={message.timestamp}>

<h3>From: {message.author.name}</h3>

<p>{message.text}</p>

<button onClick={() => handleMessageDelete(message)} disabled={messageDeleting}>

{messageDeleting ? "Deleting" : "Delete"}

</button>

</div>

),

});

Заключение

Это простой пример приложения на эффекторе с использованием React и TypeScript.

Вы можете склонировать себе репозиторий effector/examples/react-and-ts и запустить пример самостоятельно на собственном компьютере.

Перевод поддерживается сообществом

Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.

Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.

Дополнительно

RetroSearch is an open source project built by @garambo | Open a GitHub Issue

Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo

HTML: 3.2 | Encoding: UTF-8 | Version: 0.7.4