2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FrontendでEffectつかってみるかぃ?

Posted at

導入

どうも久しぶりです。
現在、TypeScriptを利用した開発に参画しており、日々、型パズルという黒魔術に苦戦しながら開発を行っています。
今回お話したいのは、Effectというライブラリについてお話したいと思います。

What is Effect?
Effect is a powerful TypeScript library designed to help developers easily create complex, synchronous, and asynchronous programs.

Effect は、開発者が複雑な同期および非同期プログラムを簡単に作成できるように設計された強力な TypeScript ライブラリです。

私自身、関数型プログラミングに魅力を感じており、特にTypeScriptを用いたfp-tsをBackendで採用してきました。関数型プログラミングの美しさや宣言的な実装は好ましく、特にpipeやtaskEitherを頻繁に利用していました。しかし、先日のTskaigi2024で、fp-tsの上位互換とされるEffectというライブラリの情報を得ました。

当初はEffectがfp-tsよりも優れているという主張に疑問を抱いていましたが、公式サイトを確認することでその違いは明白となり、Effectの導入を検討するに至りました。

導入場所

Effectを導入した具体的な場所は、Backendとの通信やサードパーティーライブラリを用いた外部接続などの非同期処理です。これを選択した理由は以下の通りです。

  非同期処理はエラーハンドリングが複雑であり、関数型プログラミングの強力なエラーハンドリング機能を活かせる。
  非同期処理はアプリケーションのパフォーマンスや信頼性に直結するため、堅牢な設計ができそう。

今回の実装では以下の4つのEffectの関数を使用しました。

    pipe - 関数を順番に適用するためのユーティリティ関数
    matchEffect -Effectの成功時、失敗字の処理を指定するための関数
    tryPromise - Promiseを扱うための関数, Promiseの成功時と失敗時の処理を定義して、Effectに変換します。
    runPromise - Effectを実行し、その結果をPromiseとして返す関数 

アーキテクチャ

Effectを採用しているアーキテクチャは以下の通りです。

    features: 画面やその対象画面に必要となる固有のコンポーネントを含む層。
    externals: 外部接続を行うことを責務とした層。
    saga: 各画面固有の外部接続処理を束ねる層。トースト通知やロギングなどもここに含める。

※ 画面側にロジックが集まりすぎると、本来の描画という責務とデータ取得・更新といった責務の境界が曖昧になるため、あえてSaga層を採用しています

実装内容

features

このコンポーネントは、ユーザーのサインインを処理します。

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ProviderType } from '@/externals/client/google-authenticate'
import { signWithBasic, signWithProvider } from '@/externals/saga/sign-in.saga'
import { SignInForm, SocialSignInButtons } from '@/features/sign-in/components'
import { useToast } from '@/providers/toast-provider'
import { SignInFormType } from '@/schemas/sign-in-form.schema'
import { useAuthStore } from '@/stores/auth.store'
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'

export const SignIn = () => {
    const navigate = useNavigate()
    const signInFn = useAuthStore.getState().signIn
    const toast = useToast()
    const signInBasicAuthenticate = useCallback(
        async ({ email, password }: SignInFormType) => {
            await signWithBasic({ email, password, navigate, signInFn, toast })
        },
        [navigate, signInFn, toast],
    )

    const signInProvider = useCallback(
        async (providerType: ProviderType) => {
            signWithProvider({ providerType, navigate, signInFn, toast })
        },
        [navigate, signInFn, toast],
    )

    return (
        <div className={'container flex h-screen w-screen flex-col items-center justify-center'}>
            <Card className={'w-full sm:w-[400px]'}>
                <CardHeader>
                    <CardTitle className={'text-2xl font-semibold tracking-tight text-center'}>
                        Sign in Account
                    </CardTitle>
                </CardHeader>
                <CardContent>
                    <p className={'text-sm text-muted-foreground mb-5 text-center'}>
                        Enter your email to sign in, your account
                    </p>
                    <SignInForm signIn={signInBasicAuthenticate} />
                    <div className={'relative flex items-center mt-6'}>
                        <div className={'flex-grow border-t border-gray-400'}></div>
                        <span
                            className={'flex-shrink mx-4 text-xs uppercase text-muted-foreground'}
                        >
                            or continue with
                        </span>
                        <div className={'flex-grow border-t border-gray-400'}></div>
                    </div>
                    <SocialSignInButtons signIn={signInProvider} />
                </CardContent>
            </Card>
        </div>
    )
}

sage

sagaフォルダ内の非同期処理の例です。
サインインの処理を非同期で行い、必要に応じてトースト通知を表示します。

syncを採用している理由としてはToastやログ出力などを同期的に行いためです。
- 同期的な処理と非同期的な処理を組み合わせて実行でき、非同期処理の中で必要な同期的なステップを明確にできる利点があります。
import {
    ProviderType,
    signWithBasicAuthenticate,
    signWithProviderOauth2,
} from '@/externals/client/google-authenticate'
import { ToastVariant } from '@/providers/toast-provider'
import { User } from '@/stores/auth.store'
import { Effect as E, pipe } from 'effect'
import { sync } from 'effect/Effect'
import { FirebaseError } from 'firebase/app'
import { UserCredential } from 'firebase/auth'
import { NavigateFunction } from 'react-router-dom'

interface SignWithBasicProp {
    email: string
    password: string
    navigate: NavigateFunction
    signInFn: (user: User) => Promise<void>
    toast: ({
        title,
        description,
        variant,
    }: {
        title: string
        description?: string
        variant: ToastVariant
    }) => void
}

const errorToastProps = {
    variant: 'destructive',
    title: 'Authentication error',
    description: 'An authentication error has occurred. Please review the information',
} as const

export const signWithBasic = ({ email, password, navigate, signInFn, toast }: SignWithBasicProp) =>
    E.runPromise(
        pipe(
            signWithBasicAuthenticate({ email, password }),
            E.matchEffect({
                onFailure: (error: FirebaseError) =>
                    sync(() => {
                        toast(errorToastProps)
                    }),
                onSuccess: (userCredential: UserCredential) =>
                    sync(async () => {
                        const user: User = {
                            id: userCredential.user.uid,
                            username: userCredential.user.email || '',
                        }
                        signInFn(user)
                        const token = await userCredential.user.getIdToken()
                        sessionStorage.setItem(import.meta.env.VITE_API_AUTH_TOKEN_KEY, token)
                        navigate('/test')
                        return userCredential
                    }),
            }),
        ),
    )

externals (一部実装は長くなるため除外しています。)

externalsフォルダ内の外部接続の例です。
外部サービスやAPIとの通信を担当します。

import { EXHAUSTIVE_CASE_ERROR } from '@/constants'
import { auth } from '@/providers/authenticate/google-oauth'
import { Effect as E } from 'effect'
import { FirebaseError } from 'firebase/app'
import {
    AuthProvider,
    GithubAuthProvider,
    GoogleAuthProvider,
    UserCredential,
    signInWithEmailAndPassword as signIn,
    signInWithPopup,
} from 'firebase/auth'

interface SignWithEmailAndPasswordProps {
    email: string
    password: string
}

export const signWithBasicAuthenticate = ({
    email,
    password,
}: SignWithEmailAndPasswordProps): E.Effect<UserCredential, FirebaseError, never> =>
    E.tryPromise({
        try: () => signIn(auth, email, password),
        catch: (error) => error as FirebaseError,
    })

Effectを利用する利点

エラーハンドリングの一貫性:
    matchEffectやtryPromiseを使用することで、エラーハンドリングが一貫して行われ、コードの読みやすさが向上します。

非同期処理の簡潔な表現:
    pipeを使うことで、非同期処理を直感的かつ簡潔に表現できます。(pipeはどの言語でも個人的にほしい)

宣言的なコードスタイル:
    Effectを使うことで、非同期処理や副作用のある処理を宣言的に記述でき、コードの可読性とメンテナンス性が向上します。

堅牢なエラーハンドリング:
    Effectは強力なエラーハンドリング機能を持ち、予期しないエラーを効果的に処理することができます。

Effectを使用することで、非同期処理の実装がより簡潔で明確になり、エラーハンドリングが強化されるため、プロジェクト全体のコード品質が向上します。特に非同期処理が頻繁に行われる場所での使用が効果的です。

終幕

まだまだ使いこなせていないというのが正直な感想です。
継続して利用しながら、これまでの経験を基に、更に効果的な使い方を見つけていきたいと思います。
Effectを通じて、関数型プログラミングの強力なエラーハンドリングや宣言的なコードスタイルを活用し、より堅牢で読みやすいコードを書き続けていきたいと思います。

Effectに興味を持たれた方は、ぜひ試してみてください。
非同期処理の管理が驚くほどシンプルで直感的になりますよ。

※ Effectについて丁寧にまとめられている方がいたので共有
わかりやすい記事


参考文献

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?