effector

Cover image for Effector with TypeScript and React
Sergey Sova for Core

Posted on • Updated on • Originally published at effector.dev

Effector with TypeScript and React

TypeScript is a typed superset of JavaScript. It became popular
recently in applications due to the benefits it can bring. If you are new to
TypeScript, it is highly recommended to become familiar with it first, before
proceeding. You can check out its documentation
here.

TypeScript has a potential to bring the following benefits to application:

  1. Type safety for state, stores and events
  2. Easy refactoring of typed code
  3. A superior developer experience in a team environment

A Practical Example

We will be going through a simplistic chat application to demonstrate a
possible approach to include static typing. This chat application will have API mock that load and saves data from localStorage.

The full source code is available on
github.
Note that, by going through this example yourself, you will experience some benefits of using TypeScript.

Let's create API mock

There is a directory structure inherited from the feature-sliced methodology.

Let's define a simple type, that our improvised API will return.

// /src/shared/api/message.ts
interface Author {
  id: string
  name: string
}

export interface Message {
  id: string
  author: Author
  text: string
  timestamp: number
}
Enter fullscreen mode Exit fullscreen mode

Our API will load and save data to localStorage, and we need some functions to load data:

// /src/shared/api/message.ts
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))
}
Enter fullscreen mode Exit fullscreen mode

I also created some libraries to generate identifier and wait to simulate network requests.

// /src/shared/lib/oid.ts
export const createOid = () =>
  ((new Date().getTime() / 1000) | 0).toString(16) +
  'xxxxxxxxxxxxxxxx'
    .replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16))
    .toLowerCase()
Enter fullscreen mode Exit fullscreen mode
// /src/shared/lib/wait.ts
export function wait(timeout = Math.random() * 1500) {
  return new Promise(resolve => setTimeout(resolve, timeout))
}
Enter fullscreen mode Exit fullscreen mode

OK. Now we can create effects that will load messages.

// /src/shared/api/message.ts
// Here effect defined with static types. void defines no arguments.
// Second type argument defines a successful result type.
// Third argument is optional and defines a failure result type.
export const messagesLoadFx = createEffect<void, Message[], Error>(async () => {
  const history = loadHistory()
  await wait()
  return history ?? []
})

interface SendMessage {
  text: string
  author: Author
}

// But we can use type inferring and set arguments types in the handler defintion.
// Hover your cursor on `messagesLoadFx` to see the inferred types:
// `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()
  },
)

// Please, note that we will `wait()` for `messagesLoadFx` and `wait()` in the current effect
// Also, note that `saveHistory` and `loadHistory` can throw exceptions,
// in that case effect will trigger `messageDeleteFx.fail` event.
export const messageDeleteFx = createEffect(async (message: Message) => {
  const history = await messagesLoadFx()
  const updated = history.filter(found => found.id !== message.id)
  await wait()
  saveHistory(updated)
})
Enter fullscreen mode Exit fullscreen mode

OK, now we are done with the messages, let's create effects to manage user session.

Really, I prefer to start design code from implementing interfaces:

// /src/shared/api/session.ts
// It is called session because it describes current user session, not the User at all.
export interface Session {
  id: string
  name: string
}
Enter fullscreen mode Exit fullscreen mode

Also, to generate a usernames and don't require to type it by themselves, import unique-names-generator:

// /src/shared/api/session.ts
import {uniqueNamesGenerator, Config, starWars} from 'unique-names-generator'

const nameGenerator: Config = {dictionaries: [starWars]}
const createName = () => uniqueNamesGenerator(nameGenerator)
Enter fullscreen mode Exit fullscreen mode

Let's create effects to manage session:

// /src/shared/api/session.ts
const LocalStorageKey = 'effector-example-session'

// We need explicitly return `null` because `undefined` is a special value in the effector ecosystem,
// that defines some "empty" state, and store will skip updates if we try to pass `undefined` inside.
// Always use `null` for "no value state".
// Note, that we need explicit types definition in that case, because `JSON.parse()` returns `any`
export const sessionLoadFx = createEffect<void, Session | null>(async () => {
  const source = localStorage.getItem(LocalStorageKey)
  await wait()
  if (!source) {
    return null
  }
  return JSON.parse(source)
})

// By default if no aruments, no explicit type arguments, and no return,
// effect will have type: `Effect<void, void, Error>`
export const sessionDeleteFx = createEffect(async () => {
  localStorage.removeItem(LocalStorageKey)
  await wait()
})

// Look at the type of the `sessionCreateFx` constant.
// It will be `Effect<void, Session, Error>` because Typescript can infer type from `session` constant
export const sessionCreateFx = createEffect(async () => {
  // I explicitly set type for the next constant, because it allows Typescript help me
  // If I forgot to set property, I'll see error in the place of definition
  // Also it allows IDE to autocomplete property names
  const session: Session = {
    id: createOid(),
    name: createName(),
  }
  localStorage.setItem(LocalStorageKey, JSON.stringify(session))
  return session
})
Enter fullscreen mode Exit fullscreen mode

How we need to import this effects?

I surely recommend to write short imports and use reexports.
It allows to securely refactor code structure inside shared/api and the same slices,
and don't worry about refactoring another imports and unnecessary changes in the git history.

// /src/shared/api/index.ts
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'
Enter fullscreen mode Exit fullscreen mode

Create a page with the logic

Typical structure of the pages:

src/
  pages/
    <page-name>/
      page.tsx — just the View layer
      model.ts — a business-logic code
      index.ts — reexports, sometimes there will be a connection code
Enter fullscreen mode Exit fullscreen mode

I recommend to write code in the view layer from the top to bottom, more common code at the top.
Let's model our view layer. We will have two main sections at the page: messages history and a message form.

// /src/pages/chat/page.tsx
import React from 'react'

export function ChatPage() {
  return (
    <div className="parent">
      <ChatHistory />
      <MessageForm />
    </div>
  )
}

function ChatHistory() {
  return (
    <div className="chat-history">
      <div>There will be messages list</div>
    </div>
  )
}

function MessageForm() {
  return (
    <div className="message-form">
      <div>There will be message form</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

OK. Now we know what kind of structure we have, and we can start to model business-logic processes.
View layer should do two tasks: render data from stores and report events to the model.
View layer doesn't know how data is loaded, how it should be converted and sent back.

// /src/pages/chat/model.ts
import {createEvent, createStore} from 'effector'

// At the moment, there is just raw data without any knowledge how to load
export const $loggedIn = createStore<boolean>(false)
export const $userName = createStore('')
export const $messages = createStore<Message[]>([])
export const $messageText = createStore('')

// Page should NOT know where the data came from.
// That's why we just reexport them.
// We can rewrite this code to `combine` or independent store,
// page should NOT be changed, just because we changed the implementation
export const $messageDeleting = messageApi.messageDeleteFx.pending
export const $messageSending = messageApi.messageSendFx.pending

// And the events report just what happened
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()
Enter fullscreen mode Exit fullscreen mode

Now we can implement components.

// /src/pages/chat/page.tsx
import {useEvent, useList, useStore} from 'effector-react'
import * as model from './model'

// export function ChatPage { ... }

function ChatHistory() {
  const messageDeleting = useStore(model.$messageDeleting)
  const onMessageDelete = useEvent(model.messageDeleteClicked)

  // Hook `useList` allows React not rerender messages really doesn't changed
  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>
  ))
  // We don't need `useCallback` here because we pass function to an HTML-element, not a custom component

  return <div className="chat-history">{messages}</div>
}
Enter fullscreen mode Exit fullscreen mode

I split MessageForm to the different components, to simplify code:

// /src/pages/chat/page.tsx
function MessageForm() {
  const isLogged = useStore(model.$loggedIn)
  return isLogged ? <SendMessage /> : <LoginForm />
}

function SendMessage() {
  const userName = useStore(model.$userName)
  const messageText = useStore(model.$messageText)
  const messageSending = useStore(model.$messageSending)

  const handleLogout = useEvent(model.logoutClicked)
  const handleTextChange = useEvent(model.messageTextChanged)
  const handleEnterPress = useEvent(model.messageEnterPressed)
  const handleSendClick = useEvent(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 = useEvent(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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Manage user session like a Pro

Let's create a session entity. An entity is a business unit.

// /src/entities/session/index.ts
import {Session} from 'shared/api'
import {createStore} from 'effector'

// Entity just stores session and some internal knowledge about it
export const $session = createStore<Session | null>(null)
// When store `$session` is updated, store `$isLogged` will be updated too
// They are in sync. Derived store are depends on data from original.
export const $isLogged = $session.map(session => session !== null)
Enter fullscreen mode Exit fullscreen mode

Now in the page we can implement login or logout features. Why not here?
If we place login logic here, we will have very implicit scenario,
when you call sessionCreateFx you won't see code called after effect.
But consequences will be visible in the DevTools and application behaviour.

Try to write the code in as obvious a way as possible in one file,
so that you and any teammate can trace the sequence of execution.

Implement logic

OK. Now we can load a user session and the messages lists on the page mount.
But, we don't have any event when we can start. Let's fix it.

You can use Gate, but I prefer to use explicit events.

// /src/pages/chat/model.ts
// Just add a new event
export const pageMounted = createEvent()
Enter fullscreen mode Exit fullscreen mode

Just add useEffect and call bound event inside.

// /src/pages/chat/page.tsx
export function ChatPage() {
  const handlePageMount = useEvent(model.pageMounted)
  React.useEffect(() => {
    handlePageMount()
  }, [handlePageMount])

  return (
    <div className="parent">
      <ChatHistory />
      <MessageForm />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Note: if you don't plan to write tests for effector code and/or implement SSR you can omit any usage of useEvent.

At the moment we can load a session and the messages list.

Just add reaction to the event, and any other code should be written in chronological order after each event:

// /src/pages/chat/model.ts
// Don't forget to import { sample } from "effector"
import {Message, messageApi, sessionApi} from 'shared/api'
import {$session} from 'entities/session'

// export stores
// export events

// Here the logic place

// You can read this code like:
// When page mounted, call messages load and session load simultaneously
sample({
  clock: pageMounted,
  target: [messageApi.messagesLoadFx, sessionApi.sessionLoadFx],
})
Enter fullscreen mode Exit fullscreen mode

After that we need to define reactions on messagesLoadFx.done and messagesLoadFx.fail, and the same for sessionLoadFx.

// /src/pages/chat/model.ts
// `.doneData` is a shortcut for `.done`, because `.done` returns `{ params, result }`
// Do not name your arguments like `state` or `payload`
// Use explicit names of the content they contain
$messages.on(messageApi.messagesLoadFx.doneData, (_, messages) => messages)

$session.on(sessionApi.sessionLoadFx.doneData, (_, session) => session)
Enter fullscreen mode Exit fullscreen mode

OK. Session and messages loaded. Let's allow user to log in.

// /src/pages/chat/model.ts
// When login clicked we need to create a new session
sample({
  clock: loginClicked,
  target: sessionApi.sessionCreateFx,
})
// When session created, just write it to a session store
sample({
  clock: sessionApi.sessionCreateFx.doneData,
  target: $session,
})
// If session create is failed, just reset the session
sample({
  clock: sessionApi.sessionCreateFx.fail,
  fn: () => null,
  target: $session,
})
Enter fullscreen mode Exit fullscreen mode

Now we'll implement a logout process:

// /src/pages/chat/model.ts
// When logout clicked we need to reset session and clear our storage
sample({
  clock: logoutClicked,
  target: sessionApi.sessionDeleteFx,
})
// In any case, failed or not, we need to reset session store
sample({
  clock: sessionApi.sessionDeleteFx.finally,
  fn: () => null,
  target: $session,
})
Enter fullscreen mode Exit fullscreen mode

Note: most of the comments wrote just for educational purpose. In the real life application code will be self-describable

But if we start the dev server and try to log in, we see nothing changed.
This is because we created $loggedIn store in the model, but don't change it. Let's fix:

// /src/pages/chat/model.ts
import {$isLogged, $session} from 'entities/session'

// At the moment, there is just raw data without any knowledge how to load
export const $loggedIn = $isLogged
export const $userName = $session.map(session => session?.name ?? '')
Enter fullscreen mode Exit fullscreen mode

Here we just reexported our custom store from the session entity, but our View layer doesn't change.
The same situation with $userName store. Just reload the page, and you'll see, that session loaded correctly.

Send message

Now we can log in and log out. I think you want to send message. This is pretty simple:

// /src/pages/chat/model.ts
$messageText.on(messageTextChanged, (_, text) => text)

// We have two different events to send message
// Let event `messageSend` react on any of them
const messageSend = merge([messageEnterPressed, messageSendClicked])

// We need to take a message text and author info then send it to the effect
sample({
  clock: messageSend,
  source: {author: $session, text: $messageText},
  target: messageApi.messageSendFx,
})
Enter fullscreen mode Exit fullscreen mode

But if in the tsconfig.json you set "strictNullChecks": true, you will see the error there.
It is because store $session contains Session | null and messageSendFx wants Author in the arguments.
Author and Session are compatible, but not the null.

To fix this strange behaviour we need to use filter there:

// /src/pages/chat/model.ts
sample({
  clock: messageSend,
  source: {author: $session, text: $messageText},
  filter: (form): form is {author: Session; text: string} => {
    return form.author !== null
  },
  target: messageApi.messageSendFx,
})
Enter fullscreen mode Exit fullscreen mode

I want to focus your attention on the return type form is {author: Session; text: string}.
This feature called type guard
and allows Typescript to reduce Session | null type to more specific Session via condition inside the function.

Now we can read this like: when message should be sent, take session and message text, check that session is exists, and send it.

OK. Now we can write a new message to a server.
But if we don't call messagesLoadFx again we didn't see any changes,
because $messages store didn't update. We can write generic code for this case.
Easiest way is to return sent message from the effect.

// /src/shared/api/message.ts
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
  },
)
Enter fullscreen mode Exit fullscreen mode

Now we can just append a message to the end of list:

// /src/pages/chat/model.ts
$messages.on(messageApi.messageSendFx.doneData, (messages, newMessage) => [
  ...messages,
  newMessage,
])
Enter fullscreen mode Exit fullscreen mode

But at the moment, sent message still left in the input.

// /src/pages/chat/model.ts
$messageText.on(messageSendFx, () => '')

// If message sending is failed, just restore the message
sample({
  clock: messageSendFx.fail,
  fn: ({params}) => params.text,
  target: $messageText,
})
Enter fullscreen mode Exit fullscreen mode

Deleting the message

There is pretty simple.

// /src/pages/chat/model.ts
sample({
  clock: messageDeleteClicked,
  target: messageApi.messageDeleteFx,
})

$messages.on(messageApi.messageDeleteFx.done, (messages, {params: toDelete}) =>
  messages.filter(message => message.id !== toDelete.id),
)
Enter fullscreen mode Exit fullscreen mode

But you can see the bug, when "Deleting" state doesn't disable.
This is because useList caches renders, and doesn't know about dependency on messageDeleting state.
To fix it we need to provide keys:

// /src/pages/chat/page.tsx
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>
  ),
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is simple example of application on effector with React and TypeScript.

You can clone this effector/examples/react-and-ts and run this example on your computer.

Discussion (0)