[ 살펴보기 ] React - v19 Stable

[ 살펴보기 ] React - v19 Stable

·

12 min read

2024년 4월에 React 19 RC가 release된 이후 제법 오랜 시간이 지나고 드디어 2024년 12월 stable version이 release 되었다. React 19 stable version에는 어떤 사항이 추가되었는지 살펴보자.

async function in transitions

React 19부터 다음과 같이 transition안에서 asyn function을 호출하면 async function이 resolve될 때 까지 isPending 상태가 true가 된다.

import axios from "axios";
import React, { Suspense, useState, useTransition } from "react";
import FetchChild from "./FetchChild";

const FetchPage = () => {
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const result = await axios.get("http://localhost:4000/orders");
    });
  };

  return (
    <div>
      <h3>Fetch Page</h3>
      <button onClick={handleSubmit} disabled={isPending}>
        Update
      </button>
    </div>
  );
};

export default FetchPage;

useActionState

Form 관련 logic을 도와주는 useActionState hook이 추가된다. ( canary release에선 useFormState라고 불렀지만 stable version에서 useActionState라고 이름이 변경되었다 ) 기본적인 사용법은 다음과 같다.

import axios from "axios";
import React, { useActionState, useState } from "react";

type ActionState = {
  fullname: string;
};

const FetchPage = () => {

  const [state, submitAction, isPending] = useActionState<
    ActionState,
    FormData
  >(
    async (previousState, formData) => {
      const firstName = formData.get("firstname");
      const lastName = formData.get("lastname");
      const result = (await axios.post("http://localhost:4000/orders"), {firstName, lastName});
      return { fullname: result.data };
    },
    { fullname: "" }
  );

  return (
    <div>
      <h3>Fetch Page</h3>
      <form action={submitAction}>
        <input type="text" name="firstname" />
        <input type="text" name="lastname" />
        <button type="submit" disabled={isPending}>
          Update
        </button>
      </form>
    </div>
  );
};

export default FetchPage;

위의 예제에서 볼 수 있듯이 useActionState는 첫 번째 parameter로 function을 전달 받고 두 번째 parameter로 initial state value를 전달 받는다. 그리고 현재 state value, useActionState에 전달한 function을 실행시키기 위한 function, 그리고 현재 pending 상태를 나타내는 isPending value를 return한다.

submitAction을 호출하면 useActionState에 전달한 function이 호출되며 호출된 function에서 return한 값이 다음 state value가 된다. isPending을 통해 useActionState에 전달한 function이 호출되었을 때 function의 실행 완료 여부 파악할 수 있다.

useActionState에 대한 자세한 정보는 documentation을 통해 확인할 수 있다. ( Reference - useActionState )

action, formAction

form, input, button element의 action, formAction prop에 function을 전달해 form data에 접근할 수 있다. 아래 예제에서 버튼을 클릭하면 action prop에 전달한 handleSubmit function이 실행되며 function에 전달된 formData를 통해 각 input이 가진 data에 접근할 수 있다. 아래 예제에서 action으로 전달한 function이 호출되면 uncontrolled input의 value는 자동으로 reset된다.

import axios from "axios";
import React, { useActionState, useState } from "react";

const FetchPage = () => {

  const handleSubmit = (formData: FormData) => {
    const firstName = formData.get("firstname");
    const lastName = formData.get("lastname");
    console.log({ firstName, lastName });
  };

  return (
    <div>
      <h3>Fetch Page</h3>
      <form action={handleSubmit}>
        <input type="text" name="firstname" />
        <input type="text" name="lastname" />
        <button type="submit">Update</button>
      </form>
    </div>
  );
};

export default FetchPage;

useFormStatus

parent component의 form submit이 발생하면 useFormStatus를 통해 child component에 직접 prop 전달 없이 parent component에서 submit된 form data와 pending 상태 등 form 관련 data를 확인할 수 있다.

import axios from "axios";
import FetchChild from "./FetchChild";

const FetchPage = () => {

  const handleSubmit = async (formData: FormData) => {
    console.log(" ::: handleSubmit ::: ");
    const firstName = formData.get("firstname");
    const lastName = formData.get("lastname");
    const result = await axios.get("http://localhost:4000/orders");
  };

  return (
    <div>
      <h3>Fetch Page</h3>
      <form action={handleSubmit}>
        <input type="text" name="firstname" />
        <input type="text" name="lastname" />
        <button type="submit">Update</button>
        <FetchChild />
      </form>
    </div>
  );
};

export default FetchPage;

FetchChild.tsx

import { memo } from "react";
import { useFormStatus } from "react-dom";

const FetchChild = () => {
  const { data, pending } = useFormStatus();

  const firstName = data?.get("firstname");
  const lastName = data?.get("lastname");

  return (
    <div>
      <h3>Fetch Child</h3>
      {pending && (
        <div>
          First name : {String(firstName)} Last name : {String(lastName)}
          submitting...
        </div>
      )}
    </div>
  );
};

export default memo(FetchChild);

위의 예제에서 child component인 FetchChild component에서 useFormStatus hook을 통해 해당 component를 감싸고 있는 form에 대한 정보에 접근하고 있다. useFormStatus hook이 return하는 value중 data를 통해 submit된 form의 input data를 read할 수 있고 pending을 통해 form의 action prop에 전달된 function의 실행이 모두 완료 되었는지 여부를 확인할 수 있다.

주의할 점은 useFormStatus hook을 사용하는 component는 parent component의 form element 안에 위치해야 하며 해당 component를 wrapping하는 form element의 정보에만 접근할 수 있다.

또한 useFormStatus가 return하는 data는 parent component의 form submit이 발생했을 때만 업데이트 되고 그 외에는 value가 null이다. pending 역시 자신을 wrapping하고 있는 parent component form의 submit이 발생하여 action prop에 전달된 function이 실행 중일 때는 true로 설정되고 그 이외는 false로 설정된다.

useOptimistic

optimistic update를 보다 쉽게 적용할 수 있는 useOptimistic hook이 추가된다. 여기서 optimistic update란 특정 데이터를 추가 하거나 업데이트할 때 api request가 아직 처리되지 않은 상태임에도 UI에 표현되는 특정 value를 미리 update 시키는 행위를 말한다. useOptimistic hook은 state와 updateFn을 parameter로 전달받고 optimisticState와 addOptimistic function을 return 한다.

useOptimistic의 첫 번째로 전달하는 state는 initial state로 return되는 state value 혹은 action이 pending 상태가 아닐 때 return되는 state value롤 전달하고 updateFn은 currentState와 optimisticValue롤 parameter로 전달 받으며 action pending 상태에서 optimistic update 결과를 return하기 위해 currentState와 opimisticValue를 merge한 형태의 data를 return한다.

useOptimistic이 return하는 optimicticState는 action이 pending 상태가 아니라면 parameter로 전달한 state와 같은 value가 되며 action이 pending 상태라면 updateFn이 return하는 value가 된다. addOptimistic function을 호출하면useOptimistic에 두 번째 parameter로 전달한 updateFn이 호출되며 addOptimistic function에 전달한 parameter 값이 updateFn이 두 번째 parameter로 전달 받는 optimisticValue가 된다.

다음 예제를 살펴보자.

App.tsx

...

export type Order = {
  name: string;
};

function App() {
  const [orderList, setOrderList] = useState<Order[]>([
    { name: "Summer shirt" },
  ]);

  const addOrder = async (formData: FormData) => {
    const newOrder = formData.get("order");
    const newOrderName = await addNewOrder(newOrder); // api request
    setOrderList((orders) => [...orders, { name: sentMessage }]);
  };

  return (
    <div id="app">
      <h3>App</h3>
      <UseOptimisticHookExaple orderList={orderList} sendOrder={addOrder} />
    </div>
  );
}

export default App;

UseOptimisticHookExample.tsx

import { useOptimistic, useRef } from "react";
import { Order } from "../../App";

type Props = {
  orderList: Order[];
  sendOrder: (formData: FormData) => Promise<void>;
};

const UseOptimisticHookExaple = ({ orderList, sendOrder }: Props) => {
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticOrder, addOptimisticOrder] = useOptimistic(
    orderList,
    (state, newMessage: any) => {
      return [...state, { name: newMessage }];
    }
  );

  const formAction = async (formData: FormData) => {
    addOptimisticOrder(formData.get("order"));
    formRef?.current?.reset();
    await sendOrder(formData);
  };

  return (
    <>
      {optimisticOrder.map((order, index) => (
        <div key={index}>{order.name}</div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type="text" name="order" placeholder="new order" />
        <button type="submit">Send</button>
      </form>
    </>
  );
};

export default UseOptimisticHookExaple;

위의 예제에서 form이 submit 되면 form action prop에 전달한 formAction function이 실행되고 formAction function에서 addOptimisticOrder를 실행하며 optimisticOrder의 value가 updateFn이 return한 값이 되도록 optimistic update를 수행한다. formAction에서 실행하는 sendOver function은 parent component에서 api request를 처리하는 function이며 해당 function이 resolve될 때 까지 action은 pending 상태가 된다.

parent component인 App.tsx에선 api request를 수행하고 결과 값을 orderList state에 optimistic update와 동일한 형태로 추가하므로 모든 작업이 완료되고 pending 상태에서 벗어나면 USeOptimisticHookExample component의 useOptimistic에는 새로운 state가 전달되어 useOptimistic이 return하는 optimisticOrder는 다시 updateFn이 return하는 값이 아닌 state로 전달 받은 값이 된다.

use api

React 19에서 추가되는 use api를 통해 promise의 값을 read하거나 context를 read하여 사용할 수 있다. 다음 예제를 살펴보자.

App.tsx

import React, { Suspense } from "react";
import UseExampleChild from "./UseExampleChild";

const UseExamplePage = () => {
  return (
    <div>
      <h3>UserExamplePage</h3>
      <Suspense fallback={<div>loading...</div>}>
        <UseExampleChild />
      </Suspense>
    </div>
  );
};

export default UseExamplePage;

UseExampleChild.tsx

import axios from "axios";
import React, { use } from "react";

const getOrderList = async () => {
  const result = await axios.get("http://localhost:4000/orders");
  return result.data;
};

const orderListResult = getOrderList();

const UseExampleChild = () => {
  const orderList = use(orderListResult);
  return (
    <div>
      <h3>UseExampleChild</h3>
      {orderList.map((order) => (
        <p>{order.name}</p>
      ))}
    </div>
  );
};

export default UseExampleChild;

위의 예제에서 orderListResult는 request return data를 담은 Promise object이므로 use api에 전달하면 그 값을 read하여 바로 사용할 수 있게 된다. 그리고 use에 전달한 promise가 pending 상태일 동안 해당 component를 wrapping하는 suspense의 fallback UI가 display된다.

use api를 통해 promise뿐만 아니라 context 역시 use를 통해 read할 수 있다.

ThemeProvider.tsx

import { createContext, PropsWithChildren, useState } from "react";

export type ThemeContext = {
  theme: string;
  changeTheme: () => void;
};

export const ThemeContext = createContext<ThemeContext | null>(null);

type Props = PropsWithChildren;

const ThemeProvider = ({ children }: Props) => {
  const [theme, setTheme] = useState("light");

  const changeTheme = () => {
    setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
  };
  return <ThemeContext value={{ theme, changeTheme }}>{children}</ThemeContext>;
};

export default ThemeProvider;

UseExamplePage.tsx

import React, { Suspense } from "react";
import UseExampleChild from "./UseExampleChild";
import ThemeProviderfrom "./ThemeProvider";

const UseExamplePage = () => {
  return (
    <div>
      <h3>UserExamplePage</h3>
      <ThemeProvider>
        <Suspense fallback={<div>loading...</div>}>
          <UseExampleChild />
        </Suspense>
      </ThemeProvider>
    </div>
  );
};

export default UseExamplePage;

UserExampleChild.tsx

import axios from "axios";
import React, { use } from "react";
import { ThemeContext } from "./UseExampleProvider";

const getOrderList = async () => {
  const result = await axios.get("http://localhost:4000/orders");
  return result.data;
};

const orderListResult = getOrderList();

const UseExampleChild = () => {
  const orderList = use(orderListResult);
  const themeContext = use(ThemeContext);
  return (
    <div>
      <h3>UseExampleChild</h3>
      <div>{themeContext?.theme}</div>
      {orderList.map((order: any) => (
        <p>{order.name}</p>
      ))}
    </div>
  );
};

export default UseExampleChild;

use를 사용할 때 주의할 점은 use에 promise를 전달할 때 component render 영역 안에서 선언된 promise는 사용하지 못한다. component render 영역 안에 선언된 promise는 re-render마다 새로 생성되므로 use에 전달하는 promise는 component render 영역 밖에 선언하여 전달하거나 promise cache를 지원하는 library 또는 framework를 사용해야 한다.

Server component

Server side에서 render되는 server component가 React 19에서 stable된다. 여기서 말하는 server란 실제 application을 제공하는 server일 수도 있고 application을 build하는 환경이 될 수도 있다.

Server component를 지원하는 NextJS를 통해 client component와의 차이점을 살펴보자. 우선 client component에서 tanstack query와 같은 library 없이 component에서 api server에서 data를 fetch하고 display하는 방법은 다음과 같을 것이다.

"use client";
import axios from "axios";
import React, { useEffect, useState } from "react";

type Order = {
  name: string;
};

const OrderPage = () => {
  const [orderList, setOrderList] = useState<Order[] | null>(null);
  useEffect(() => {
    const getOrderList = async () => {
      const result = await axios.get("http://localhost:4000/orders");
      setOrderList(result.data);
    };
    getOrderList();
  }, []);
  return (
    <div>
      {orderList && orderList.map((order) => <p>{order.name}</p>)}
    </div>
  );
};

export default OrderPage;

NextJS( app route )에선 위의 예제와 같이 “use client”라는 directive를 페이지 상단에 선언함으로서 해당 component가 client component임을 알린다. 위의 client component에서 user가 접근하는 page에서 order list를 출력하기 위해서 user는 우선 application html을 load 받고 이 후 application을 구성하는 javascript를 load 받고 나서 api server에 request를 보낸 뒤 order list를 출력한다.

이제 위의 예제를 server component 변경해서 살펴보자.

import axios from "axios";
import React from "react";

type Order = {
  name: string;
};

const OrderPage = async () => {
  const result = await axios.get<Order[]>("http://localhost:4000/orders");
  const orderList = result.data;
  return (
    <div>
      {orderList && orderList.map((order) => <p>{order.name}</p>)}
    </div>
  );
};

export default OrderPage;

client component와는 다르게 page 상단에 “use client” directive를 사용하지 않으며 component level에서 async keyword를 사용할 수 있다. 위의 component는 data fetch와 order list render가 모두 server side에서 이루어지고 client side로 전달된다. 여기서 중요한 것은 client로 전달되는 내용은 server에서 render된 component의 react element ( virtual dom을 이루는 )가 될 수도 있고 server에서 render된 react element과 더불어 해당 element를 기반으로 생성된 실제 html elements일 수도 있다.

최종적으로 client side에 무엇을 전달할 것인가는 server component를 제공하는 code를 어떻게 구현 하느냐에 달려있으므로 server component가 반드시 server side에서 html이 생성되는 것을 의미하는 것은 아님을 기억하자.

Server Functions ( Server Actions )

React 19에서 server function이 stable된다. Server functions은 server side에서 실행되는 function을 말한다. Client side에서 server function을 실행하면 해당 function은 server side에서 실행되며 결과 값을 다시 호출부로 return한다. “use server” directive를 사용해 function이 server side에서 실행되어야 하는 function임을 명시할 수 있다.

NextJS를 통해 server function 예시를 살펴보자.

server-utils.ts

"use server";
import axios from "axios";

type Order = {
  name: string;
};

export const getOrderDetail = async (id: string) => {
  const result = await axios.get<Order[]>("http://localhost:4000/orders", {
    params: {
      orderId: id,
      clientId: process.env.CLIENT_ID,
    },
  });
  return result.data;
};

OrderPage.tsx

"use client";
import { getOrderDetail } from "@/app/[locale]/user/_utils/server-utils";
import React from "react";

const OrderPage = () => {
  const requestOrderDetail = async () => {
    const result = await getOrderDetail("123");
  };
  return (
    <div>
      <button onClick={requestOrderDetail}>Request Order Detail</button>
    </div>
  );
};

export default OrderPage;

위의 예제에서 server-utils.ts 파일 상단에 “use server” directive를 사용해 해당 파일에서 선언된 function이 모두 server function임을 명시해주고 있다. 그리고 OrderPage라는 client component에서 requestOrderDetail function을 실행하면 server function을 호출하고 server에서 실행된 server function의 실행 값을 반환 받는다.

여기서 혼동하지 쉬운 부분이 “use server” directive는 server function을 사용할 때 사용하는 directive이며 server component에선 “use server”라는 directive를 사용하지 않는다. NextJS에선 “use server” 또는 “use client”와 같은 directive가 없는 component를 default로 server component로 취급한다.

ref as a prop

React 19부터 functional component로 ref prop을 전달할 때 더 이상 component를 forwardRef로 wrapping하지 않아도 된다.

TestInput.tsx

import React, { useState } from "react";

type Props = {
  ref: React.RefObject<HTMLInputElement | null>;
};

const TestInput = ({ ref }: Props) => {
  const [inputVal, setInputVal] = useState("");
  return (
    <div>
      <input
        ref={ref}
        type="text"
        value={inputVal}
        onChange={(e) => {
          setInputVal(e.target.value);
        }}
      />
    </div>
  );
};

export default TestInput ;

TestPage.tsx

"use client";
import TestInput from "@/app/user/_sections/Test";
import React, { useRef } from "react";

const TestPage = () => {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const handleInputFocus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <TestInput ref={inputRef} />
      <div>
        <button onClick={handleInputFocus}>Focus</button>
      </div>
    </div>
  );
};

export default TestPage;

Context as a provider

React 19부터 Context provider를 선언할 때 <Context.Provier> 대신 <Context>와 같이 Provider 부분을 생략하여 사용할 수 있다.

import { createContext, PropsWithChildren, useState } from "react";

export type ThemeContext = {
  theme: string;
  changeTheme: () => void;
};

export const ThemeContext = createContext<ThemeContext | null>(null);

type Props = PropsWithChildren;

const ThemeProvider = ({ children }: Props) => {
  const [theme, setTheme] = useState("light");

  const changeTheme = () => {
    setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
  };
  return <ThemeContext value={{ theme, changeTheme }}>{children}</ThemeContext>;
};

export default ThemeProvider ;
import React from "react";
import ThemeProvider from "./ThemeProvider ";

const App = () => {
  return (
      <ThemeProvider>
       ...
      </ThemeProvider>
  );
};

export default App;

clean up function for refs

React 19부터 ref callback에서 clearn up function을 return할 수 있게 된다. 다음 예제에서 TextInput component가 unmount될 때 ref callback에서 return하는 clean up function이 실행된다.

import React, { useState } from "react";

const TestInput = () => {
  return (
    <div>
      <input
        type="text"
        className="text-black"
        ref={(ref) => {
          return () => {
            console.log(" ::: input clean up :::  ");
          };
        }}
      />
    </div>
  );
};

export default TestInput;

useDeferredValue initial value

React 19부터 useDeferredValue hook에 initialValue를 설정할 수 있다. initialValue가 제공 되면 background에서 useDeferredValue의 첫 번째 paramerer로 전달된 deferredValue ( 아래 예제에선 name state )를 통해 re-render tree를 구성할 동안 initialValue가 component의 initial render를 위해 사용된다.

import React, { useDeferredValue, useState, useTransition } from "react";
import InputChild from "./InputChild";

const InputPage = () => {
  const [name, setName] = useState("");
  const deferredName = useDeferredValue(name, "jack");

  const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  return (
    <div>
      <h3>InputPage</h3>
      <input
        type="text"
        value={name}
        onChange={(e) => {
          handleUpdate(e);
        }}
      />
      <InputChild testName={deferredName} />
    </div>
  );
};

export default InputPage;

Support for document metadata

React 19부터 component level에서 선언된 meta tag는 자동으로 document의 <head> section으로 추가된다.

const UseExamplePage = () => {
  return (
    <>
      <title>Example Page</title>
      <meta name="author" content="Jack" />
      <div>
        <h3>UserExamplePage</h3>
      </div>
    </>
  );
};

export default UseExamplePage;

예를 들어 위와 같이 특정 component에 title, meta tag를 추가하고 application을 실행 해보면 다음과 같이 document <head> section에 추가된 것을 확인할 수 있다.

React 19부터 위와 같은 방법을 통해 component level에서 meta tag를 사용할 수 있지만 위와 같은 기능이 react-helmet과 같은 library를 대체하는 것은 아니다. 위와 같은 기능은 react-helmet과 같은 library가 metadata 관련 기능 보다 쉽게 제공할 수 있도록 도와주는 기능이므로 간단한 경우가 아니면 react-helmet과 같은 library를 사용하는 것이 좋다.

위에서 소개한 사항 외에도 support for stylesheets, suport for asyn scripts, support for preloading resources 등 React 19를 통해 추가된 사항이 더 존재하며 모든 추가 사항은 Release Note를 통해 확인할 수 있다. ( Reference - React v19 )