ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 모두가 기다리던 XState 완벽 가이드: 시작부터 실전까지
    Javascript 2024. 9. 21. 16:15

    모두가 기다리던 XState 완벽 가이드: 시작부터 실전까지

    React 상태 관리에 고민이 많으셨나요?

     

    이번에는 XState를 활용한 상태 관리의 모든 것을 알아볼 텐데요.

     

    하지만 먼저, 왜 XState를 배워야 할까요?

     

    제 답변은 간단합니다:

     

    XState는 여러분이 필요로 하는 마지막 상태 관리 라이브러리입니다.

     

    다른 상태 관리 라이브러리들(Redux, MobX, Zustand)이 특정 사용 사례에 적합한 반면, XState는 모든 가능한 사용 사례를 포괄하는 완전한 상태 조정 솔루션인데요.

     

    그럼 단점은 무엇일까요?

     

    단 두 단어로 정리할 수 있습니다: 학습 곡선(learn curve).

     

    XState는 다른 모든 라이브러리와는 완전히 다른 모델을 기반으로 구축되었는데요.

     

    직접적인 상태 변화(useState), 리듀서(Redux), 혹은 세트 메서드(Zustand) 대신, XState는 배우 모델(actor model)과 상태 기계(state machines)를 기반으로 합니다.

     

    이 새로운 방식은 초보자에게는 XState를 배우기 어렵게 만드는데요.

     

    XState는 모든 상태와 그 상호작용을 명시적으로 만들기 때문에, 초기에는 코드가 더 장황하고 복잡해 보입니다.


    XState의 핵심 개념: Actors

    XState(v5)는 상태 기계(state machines)보다는 actors에 더 중점을 두는데요.

    Actor는 다음을 포함하는 프로세스입니다:

    • 자체 상태를 유지
    • 이벤트를 보내고 받아 다른 프로세스와 상호작용

    XState는 여러 종류의 actor를 제공합니다.

    이 강좌를 통해 모든 종류의 actor에 대해 배우게 될 건데요:

    • fromTransition: 리듀서 함수로부터의 actor
    • fromPromise: 비동기 함수로부터의 actor
    • setup + createMachine: 상태 기계 actor
    • fromCallback: 부모 프로세스에 이벤트를 다시 보낼 수 있는 actor
    • fromObservable: 값의 관찰 가능한 스트림을 나타내는 actor

    상태 기계는 특정 유형의 actor일 뿐이며, 유일한 유형의 actor가 아닙니다.

     

    사실, 가장 완전한 상태 관리 추상화 방식이지만, 모든 사용 사례에 반드시 필요하지는 않는데요.

     

    항상 가장 간단한 actor부터 시작하는 것을 추천드립니다.


    예제를 통한 비교

    대부분 useState, useReducer, 그리고 XState를 사용하여 동일한 예제를 포함하고 있는데요.

     

    useReducer 예제는 useStateXState actors 간의 격차를 메우는 데 도움이 될 겁니다.

    첫 번째 예제: useState로 버튼 토글 구현하기

    가장 간단한 예제인 버튼 토글부터 시작해볼까요?

    import { useState } from "react";
    
    type Context = boolean;
    const initialContext = false;
    
    export default function UseState() {
      const [context, setContext] = useState<Context>(initialContext);
      return (
        <button onClick={() => setContext(!context)}>
          {context ? "On" : "Off"}
        </button>
      );
    }

     

    useState를 사용하면 버튼의 상태를 간단하게 관리할 수 있는데요.

     

    contexttrue이면 "On", false이면 "Off"로 표시됩니다.


    useReducer로 버튼 토글 구현하기

    이제 useReducer를 사용하여 동일한 버튼 토글을 구현해보겠습니다.

    import { useReducer } from "react";
    
    type Event = { type: "toggle" };
    type Context = boolean;
    const initialContext = false;
    
    const reducer = (context: Context, event: Event): Context => {
      if (event.type === "toggle") {
        return !context;
      }
      return context;
    };
    
    export default function UseReducer() {
      const [context, dispatch] = useReducer(reducer, initialContext);
      return (
        <button onClick={() => dispatch({ type: "toggle" })}>
          {context ? "On" : "Off"}
        </button>
      );
    }

     

    useReducer를 사용하면 상태 변화를 이벤트 기반으로 처리할 수 있는데요.

     

    이렇게 하면 상태 관리 로직이 컴포넌트 외부로 분리되어 가독성이 높아집니다.


    XState로 버튼 토글 구현하기

    마지막으로 XState를 사용하여 버튼 토글을 구현해보겠습니다.

    import { useActor } from "@xstate/react";
    import { fromTransition } from "xstate";
    
    type Event = { type: "toggle" };
    type Context = boolean;
    const initialContext = false;
    
    const toggleActor = fromTransition(
      (context: Context, event: Event): Context => {
        if (event.type === "toggle") {
          return !context;
        }
        return context;
      },
      initialContext
    );
    
    export default function ToggleButton() {
      const [state, send] = useActor(toggleActor);
      return (
        <button onClick={() => send({ type: "toggle" })}>
          {state ? "On" : "Off"}
        </button>
      );
    }

     

    XStatefromTransition을 사용하면 useReducer와 유사한 방식으로 상태를 관리할 수 있는데요.

     

    하지만 XState의 Actor 모델을 통해 더 복잡한 상태 관리도 이어서 구현할 수 있게 됩니다.


    Context 업데이트를 위한 액션 사용

    XState에서는 context 업데이트와 같은 동기적인 효과를 actions라고 부르는데요.

    import { assign, setup } from "xstate";
    
    type Event = { type: "toggle" };
    type Context = { toggleValue: boolean };
    const initialContext: Context = { toggleValue: false };
    
    const machine = setup({
      types: {
        events: {} as Event,
        context: {} as Context,
      },
      actions: {
        onToggle: assign(({ context }) => ({ toggleValue: !context.toggleValue })),
      },
    }).createMachine({
      context: initialContext,
      initial: "Idle",
      states: {
        Idle: {
          on: {
            toggle: {
              actions: "onToggle",
            },
          },
        },
      },
    });

     

    이렇게 하면 "toggle" 이벤트가 발생할 때마다 onToggle 액션이 실행되어 toggleValue가 반전됩니다.


    컴포넌트에서 Context 사용하기

    상태 기계를 컴포넌트에 연결하여 실제로 동작하도록 해보겠습니다.

    import { useActor } from "@xstate/react";
    import { assign, setup } from "xstate";
    
    type Event = { type: "toggle" };
    type Context = { toggleValue: boolean };
    const initialContext: Context = { toggleValue: false };
    
    const machine = setup({
      types: {
        events: {} as Event,
        context: {} as Context,
      },
      actions: {
        onToggle: assign(({ context }) => ({ toggleValue: !context.toggleValue })),
      },
    }).createMachine({
      context: initialContext,
      initial: "Idle",
      states: {
        Idle: {
          on: {
            toggle: {
              actions: "onToggle",
            },
          },
        },
      },
    });
    
    export default function MachineContext() {
      const [snapshot, send] = useActor(machine);
      return (
        <button onClick={() => send({ type: "toggle" })}>
          {snapshot.context.toggleValue ? "On" : "Off"}
        </button>
      );
    }

     

    이렇게 하면 버튼을 클릭할 때마다 toggleValue가 업데이트되고, 버튼의 텍스트도 변경됩니다.


    폼 상태 관리: useState, useReducer, XState 비교 분석

    폼을 만들 때 상태 관리를 어떻게 해야 할까요?

     

    이번에는 useState, useReducer, XState를 활용하여 폼을 구현하는 방법을 비교해 보겠습니다.

    useState를 사용한 폼 구현

    import { useState } from "react";
    import { initialContext, postRequest, type Context } from "./shared";
    
    export default function UseState() {
      const [context, setContext] = useState<Context>(initialContext);
      const [loading, setLoading] = useState(false);
    
      const onUpdateUsername = (value: string) => {
        setContext({ username: value });
      };
    
      const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        if (!loading) {
          setLoading(true);
          await postRequest(context);
          setLoading(false);
        }
      };
    
      return (
        <form onSubmit={onSubmit}>
          <input
            type="text"
            value={context.username}
            onChange={(e) => onUpdateUsername(e.target.value)}
          />
          <button type="submit" disabled={loading}>
            Confirm
          </button>
        </form>
      );
    }

    useReducer를 사용한 폼 관리

    import { useReducer } from "react";
    import { initialContext, postRequest, type Context } from "./shared";
    
    type Event =
      | { type: "update-username"; value: string }
      | { type: "update-loading"; value: boolean };
    
    type ReducerContext = Context & {
      loading: boolean;
    };
    
    const reducer = (context: ReducerContext, event: Event): ReducerContext => {
      switch (event.type) {
        case "update-username":
          return { ...context, username: event.value };
        case "update-loading":
          return { ...context, loading: event.value };
        default:
          return context;
      }
    };
    
    export default function UseReducer() {
      const [context, dispatch] = useReducer(reducer, {
        ...initialContext,
        loading: false,
      });
    
      const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        if (!context.loading) {
          dispatch({ type: "update-loading", value: true });
          await postRequest(context);
          dispatch({ type: "update-loading", value: false });
        }
      };
    
      return (
        <form onSubmit={onSubmit}>
          <input
            type="text"
            value={context.username}
            onChange={(e) =>
              dispatch({ type: "update-username", value: e.target.value })
            }
          />
          <button type="submit" disabled={context.loading}>
            Confirm
          </button>
        </form>
      );
    }

    XState로 폼 관리하기

    import { useActor } from "@xstate/react";
    import { assign, fromPromise, setup } from "xstate";
    import { initialContext, postRequest, type Context } from "./shared";
    
    type Event =
      | { type: "update-username"; username: string }
      | { type: "submit"; event: React.FormEvent<HTMLFormElement> };
    
    const submitActor = fromPromise(
      async ({
        input,
      }: {
        input: { event: React.FormEvent<HTMLFormElement>; context: Context };
      }) => {
        input.event.preventDefault();
        await postRequest(input.context);
      }
    );
    
    const machine = setup({
      types: {
        context: {} as Context,
        events: {} as Event,
      },
      actors: { submitActor },
    }).createMachine({
      context: initialContext,
      initial: "Editing",
      states: {
        Editing: {
          on: {
            "update-username": {
              actions: assign(({ event }) => ({
                username: event.username,
              })),
            },
            submit: { target: "Loading" },
          },
        },
        Loading: {
          invoke: {
            src: "submitActor",
            input: ({ event, context }) => {
              assertEvent(event, "submit");
              return { event: event.event, context };
            },
            onDone: { target: "Complete" },
          },
        },
        Complete: {},
      },
    });
    
    export default function Machine() {
      const [snapshot, send] = useActor(machine);
      return (
        <form onSubmit={(event) => send({ type: "submit", event })}>
          <input
            type="text"
            value={snapshot.context.username}
            onChange={(e) =>
              send({ type: "update-username", username: e.target.value })
            }
          />
          <button type="submit" disabled={snapshot.matches("Loading")}>
            Confirm
          </button>
        </form>
      );
    }

    React에서 비동기 검색 구현: useState와 XState의 차이점 알아보기

    이번에는 React에서 비동기 검색 기능을 구현하는 방법을 알아볼 텐데요.

    useState와 useEffect로 검색 기능 구현하기

    import { useEffect, useState } from "react";
    import { initialContext, searchRequest, type Context } from "./shared";
    
    export default function UseState() {
      const [context, setContext] = useState<Context>(initialContext);
    
      const submitSearch = async () => {
        const newPosts = await searchRequest(context.query);
        setContext({ ...context, posts: newPosts });
      };
    
      useEffect(() => {
        submitSearch();
      }, []);
    
      return (
        <div>
          <div>
            <input
              type="search"
              value={context.query}
              onChange={(e) => setContext({ ...context, query: e.target.value })}
            />
            <button type="button" onClick={submitSearch}>
              Search
            </button>
          </div>
    
          {context.posts.map((post) => (
            <div key={post.id}>
              <p>{post.title}</p>
              <p>{post.body}</p>
            </div>
          ))}
        </div>
      );
    }

    XState로 검색 기능 구현하기

    import { useMachine } from "@xstate/react";
    import { assign, fromPromise, setup } from "xstate";
    import { initialContext, searchRequest, type Context } from "./shared";
    
    type Event =
      | { type: "update-query"; value: string }
      | { type: "submit-search" };
    
    const searchingActor = fromPromise(
      async ({ input }: { input: { query: string } }) =>
        searchRequest(input.query)
    );
    
    const machine = setup({
      types: {
        events: {} as Event,
        context: {} as Context,
      },
      actors: { searchingActor },
    }).createMachine({
      context: initialContext,
      initial: "Searching",
      states: {
        Searching: {
          invoke: {
            src: "searchingActor",
            input: ({ context }) => ({ query: context.query }),
            onDone: {
              target: "Idle",
              actions: assign(({ event }) => ({
                posts: event.output,
              })),
            },
          },
        },
        Idle: {
          on: {
            "update-query": {
              actions: assign(({ event }) => ({
                query: event.value,
              })),
            },
            "submit-search": { target: "Searching" },
          },
        },
      },
    });
    
    export default function Machine() {
      const [snapshot, send] = useMachine(machine);
      return (
        <div>
          <div>
            <input
              type="search"
              value={snapshot.context.query}
              onChange={(e) =>
                send({ type: "update-query", value: e.target.value })
              }
            />
            <button type="button" onClick={() => send({ type: "submit-search" })}>
              Search
            </button>
          </div>
    
          {snapshot.context.posts.map((post) => (
            <div key={post.id}>
              <p>{post.title}</p>
              <p>{post.body}</p>
            </div>
          ))}
        </div>
      );
    }

    마무리하며

    이제 XState의 강력한 기능과 다양한 패턴을 이해하셨나요?

     

    이번 강좌를 통해 상태 관리에 대한 새로운 통찰을 얻으셨길 바랍니다.

     


     

Designed by Tistory.