메인 콘텐츠로 건너뛰기

React란

React는 Meta(구 Facebook)가 개발 · 유지하는 사용자 인터페이스 구축을 위한 JavaScript 라이브러리입니다. 선언적인 UI 기술과 컴포넌트 기반의 아키텍처가 특징으로, 소규모 위젯부터 풀 SPA까지 폭넓게 활용되고 있습니다. React의 핵심은 가상 DOM을 통한 효율적인 재렌더링입니다. 상태(state)가 변화하면 React는 차분만을 DOM에 반영하므로, 수동으로 DOM을 조작할 필요가 없습니다.
이 페이지에서 설명하는 것은 React 19와 Inertia v3의 조합입니다. Laravel 13의 스타터 킷은 이 구성을 기본으로 사용합니다.

JSX와 TSX

React 컴포넌트는 JSX (JavaScript XML)라는 구문으로 작성합니다. HTML과 비슷한 표기법을 JavaScript 안에 직접 쓸 수 있습니다.
// JSX 예
function Greeting({ name }) {
    return <h1>안녕하세요, {name}</h1>
}
스타터 킷에서는 TypeScript(.tsx)가 표준 채택되어 있습니다. 타입 정의에 의해 IDE의 자동완성이 강화되고, 버그를 조기에 발견할 수 있습니다.
// TSX 예 (TypeScript)
type Props = {
    name: string
}

function Greeting({ name }: Props) {
    return <h1>안녕하세요, {name}</h1>
}
Laravel의 React 스타터 킷은 TypeScript + TSX가 표준입니다. 본 페이지의 예시도 모두 TSX로 기술합니다.

Laravel에서의 포지션

역사

React와 Laravel의 관계는 Vue보다 다소 짧지만, 현재는 동등 이상의 대우가 되어 있습니다. **Laravel 6 (2019년)**에서 인증 스캐폴드가 laravel/ui 패키지로 분리되어, Vue와 함께 React 버전 스캐폴드도 제공되었습니다. 다만 당시에는 Vue가 주류였으며 React 버전의 존재감은 약한 상황이었습니다. **Laravel Breeze (2021년)**에 Inertia + React 스택이 추가되면서 본격적인 채용이 시작되었고, **Laravel 12 (2025년)**의 스타터 킷 쇄신으로 React가 Vue와 완전히 동렬(오히려 먼저 표시됨)의 대우가 되었습니다.

현재의 주류 스타일: Inertia × React

현재 Laravel에서의 React 사용법의 중심은 Inertia × React입니다. Inertia는 API를 설계하지 않고 Laravel의 컨트롤러에서 직접 React 컴포넌트에 데이터를 전달할 수 있는 “모던 모놀리스” 아키텍처를 실현합니다.

셋업

스타터 킷 경유 (권장)

신규 프로젝트에서 시작하는 경우에는 스타터 킷을 사용하는 것이 가장 손쉽습니다.
laravel new my-app
대화식 프롬프트에서 React를 선택하면 이하가 모두 자동으로 셋업됩니다.
  • inertiajs/inertia-laravel (서버사이드 어댑터)
  • @inertiajs/react (클라이언트 어댑터)
  • react + react-dom (React 19 본체)
  • @vitejs/plugin-react (Vite 플러그인)
  • TypeScript + @types/react
  • Tailwind CSS + shadcn/ui 컴포넌트 라이브러리
  • HandleInertiaRequests 미들웨어
  • 로그인 · 회원가입 등의 인증 화면 (Inertia + React + TypeScript로 구현 완료)

수동 설치

기존 프로젝트에 추가하는 경우에는 서버사이드와 클라이언트사이드를 별도로 설치합니다.
# 서버사이드 (PHP)
composer require inertiajs/inertia-laravel

# 클라이언트사이드 (JavaScript)
npm install @inertiajs/react react react-dom
npm install --save-dev @vitejs/plugin-react @types/react @types/react-dom typescript
다음으로, vite.config.ts에 React 플러그인을 추가합니다.
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import react from '@vitejs/plugin-react'

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.tsx'],
            refresh: true,
        }),
        react(),
    ],
})
resources/js/app.tsx에서 Inertia 앱을 기동합니다.
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'

createInertiaApp({
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob('./pages/**/*.tsx'),
        ),
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />)
    },
})
수동 설치의 세부 내용(루트 템플릿 설정이나 미들웨어 등록 등)은 Inertia 공식 문서를 참고하세요.

디렉터리 구조

스타터 킷에서는 React의 페이지 컴포넌트를 resources/js/pages/ 디렉터리에 배치합니다.
resources/js/
├── app.tsx            # Inertia 앱의 기점
├── bootstrap.ts
├── components/        # 재이용 가능한 UI 컴포넌트
│   ├── ui/            # shadcn/ui 컴포넌트
│   └── ...
├── hooks/             # 커스텀 React 훅
├── layouts/           # 레이아웃 컴포넌트
│   ├── app-layout.tsx
│   └── auth-layout.tsx
├── lib/               # 유틸리티 함수 · 설정
├── pages/             # Inertia 페이지 컴포넌트 (컨트롤러 이름에 대응)
│   ├── auth/
│   │   ├── login.tsx
│   │   └── register.tsx
│   ├── dashboard.tsx
│   └── posts/
│       ├── index.tsx
│       ├── create.tsx
│       └── show.tsx
└── types/             # TypeScript 타입 정의
Inertia::render('posts/index', [...])이라고 쓰면 resources/js/pages/posts/index.tsx가 대응하는 컴포넌트가 됩니다.

JSX 구문의 기초

React는 JSX를 사용합니다. JavaScript 안에 HTML 라이크한 구문을 쓰는 스타일로, 스타터 킷의 코드를 읽기 위해 최소한 알아 두고 싶은 패턴을 소개합니다.

{} — 변수 전개

JSX 내에서는 {}를 사용하여 JavaScript의 값이나 식을 삽입합니다.
const name = '세계'
const count = 3

return (
    <div>
        <p>안녕하세요, {name}!</p>
        <p>2배는 {count * 2}</p>
    </div>
)

조건 분기 — &&와 삼항 연산자

React에는 v-if에 해당하는 디렉티브가 없습니다. 심플한 조건에는 && 연산자, if/else에는 삼항 연산자 ? :를 사용합니다.
{/* 조건이 true일 때만 표시 */}
{isLoggedIn && <p>어서 오세요!</p>}

{/* if / else */}
{isLoggedIn ? <p>어서 오세요!</p> : <a href="/login">로그인</a>}

{/* if / else if / else */}
{isLoggedIn
    ? <p>어서 오세요!</p>
    : role === 'admin'
        ? <p>관리자로 로그인 중</p>
        : <a href="/login">로그인</a>
}

리스트 렌더링 — .map()

리스트 렌더링에는 Array.map()을 사용합니다. 효율적인 차분 갱신을 위해 key prop은 반드시 지정합니다.
<ul>
    {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
    ))}
</ul>
Vue의 v-for :key나 Svelte의 {#each}에 해당합니다.

className — CSS 클래스명

JSX는 JavaScript로 컴파일되므로, class는 예약어입니다. CSS 클래스에는 className을 사용합니다.
{/* HTML: <div class="container"> */}
<div className="container">...</div>

이벤트 핸들러 — 캐멀 케이스

JSX의 이벤트 속성은 캐멀 케이스이며, 함수의 참조를 전달합니다.
<button onClick={handleClick}>클릭</button>

{/* 폼 송신의 기본 동작 취소 */}
<form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
    ...
</form>

페이지 컴포넌트의 기본

Inertia의 페이지 컴포넌트는 일반적인 React 컴포넌트입니다. Laravel의 컨트롤러에서 전달된 데이터를 props로 받을 수 있습니다.

컨트롤러

// app/Http/Controllers/PostController.php
use Inertia\Inertia;
use App\Models\Post;

class PostController extends Controller
{
    public function index()
    {
        return Inertia::render('posts/index', [
            'posts' => Post::latest()->paginate(10),
        ]);
    }
}

React 페이지 컴포넌트

// resources/js/pages/posts/index.tsx
import { Link } from '@inertiajs/react'

type Post = {
    id: number
    title: string
    created_at: string
}

type Props = {
    posts: {
        data: Post[]
        // paginate(10)은 페이지네이션 정보도 포함하는 오브젝트를 반환
        // current_page, last_page, per_page, total 등도 이용 가능
    }
}

export default function PostsIndex({ posts }: Props) {
    return (
        <div>
            <h1>게시물 목록</h1>
            {posts.data.map((post) => (
                <article key={post.id}>
                    <h2>
                        <Link href={`/posts/${post.id}`}>{post.title}</Link>
                    </h2>
                    <p>{post.created_at}</p>
                </article>
            ))}
        </div>
    )
}
컴포넌트의 인자로 props를 받는 것만으로, 컨트롤러에서 전달된 데이터를 그대로 사용할 수 있습니다. REST API를 정의할 필요는 없습니다.
@inertiajs/react가 제공하는 <Link> 컴포넌트를 사용하면, 페이지 전환이 XHR로 이루어져 브라우저의 풀 리로드를 회피할 수 있습니다.
import { Link } from '@inertiajs/react'

export default function PostsIndex() {
    return (
        <div>
            {/* 기본 링크 */}
            <Link href="/posts">게시물 목록</Link>

            {/* POST 메서드로 링크 (삭제 등) */}
            <Link href="/posts/1" method="delete" as="button">
                삭제
            </Link>

            {/* 프리로드 (호버 시 사전 취득) */}
            <Link href="/posts/1" preload>
                게시물 보기
            </Link>
        </div>
    )
}
일반적인 <a> 태그와 동일하게 작성할 수 있지만, 뒤에서 Inertia가 페이지 컴포넌트만을 교체하므로 SPA와 같은 조작감이 됩니다.

Form 컴포넌트

@inertiajs/react가 제공하는 <Form> 컴포넌트는 스타터 킷의 인증 화면에서 사용되는 폼 송신의 권장 스타일입니다. actionmethod를 props로 지정하고, children 함수(render prop)로 errorsprocessing을 받습니다.

기본 사용법

import { Form } from '@inertiajs/react'

export default function PostCreate() {
    return (
        <Form action="/posts" method="post" className="flex flex-col gap-4">
            {({ errors, processing }) => (
                <>
                    <div>
                        <label htmlFor="title">제목</label>
                        <input id="title" name="title" type="text" required />
                        {errors.title && <p className="error">{errors.title}</p>}
                    </div>

                    <div>
                        <label htmlFor="content">본문</label>
                        <textarea id="content" name="content" />
                        {errors.content && <p className="error">{errors.content}</p>}
                    </div>

                    <button type="submit" disabled={processing}>
                        {processing ? '전송 중...' : '게시하기'}
                    </button>
                </>
            )}
        </Form>
    )
}
<Form>의 children은 ({ errors, processing }) => JSX라는 함수(render prop 패턴)입니다. Form 컴포넌트가 이 값들을 자동으로 산출하여 전달해 줍니다. 폼 필드에는 onChange 핸들러가 아니라 HTML 네이티브의 name 속성을 사용하며, 브라우저의 표준 폼 데이터 수집이 기능합니다.

스타터 킷의 패턴

스타터 킷은 Wayfinder를 사용하여 라우트를 오브젝트로 관리하고 있습니다. store.form()은 라우트 오브젝트의 actionmethod를 포함하는 오브젝트를 반환하고, <Form>에 spread합니다.
import { Form } from '@inertiajs/react'
import { store } from '@/routes/login'

export default function Login() {
    return (
        <Form
            {...store.form()}
            resetOnSuccess={['password']}
            className="flex flex-col gap-6"
        >
            {({ errors, processing }) => (
                <>
                    {/* 폼 내용 */}
                </>
            )}
        </Form>
    )
}
resetOnSuccess에 지정한 필드는 송신 성공 시에 자동으로 리셋됩니다. 패스워드 필드 등 송신 후에 비우고 싶은 필드에 지정합니다.
Wayfinder를 사용하지 않는 경우는 action="/login"처럼 직접 URL을 전달하면 같은 방식으로 동작합니다.

useForm

폼 처리에는 @inertiajs/reactuseForm 훅을 사용합니다. 폼 상태 관리 · 송신 · 유효성 검증 오류 표시가 심플하게 구현됩니다.

컨트롤러 측

// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title'   => ['required', 'string', 'max:255'],
            'content' => ['required', 'string'],
        ]);

        Post::create($validated + ['user_id' => auth()->id()]);

        return redirect()->route('posts.index')
            ->with('success', '게시물을 작성했습니다.');
    }
}

React 폼 컴포넌트

// resources/js/pages/posts/create.tsx
import { useForm } from '@inertiajs/react'
import { FormEventHandler } from 'react'

export default function PostCreate() {
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        content: '',
    })

    const submit: FormEventHandler = (e) => {
        e.preventDefault()
        post('/posts')
    }

    return (
        <form onSubmit={submit}>
            <div>
                <label>제목</label>
                <input
                    type="text"
                    value={data.title}
                    onChange={(e) => setData('title', e.target.value)}
                />
                {errors.title && <p className="error">{errors.title}</p>}
            </div>

            <div>
                <label>본문</label>
                <textarea
                    value={data.content}
                    onChange={(e) => setData('content', e.target.value)}
                />
                {errors.content && <p className="error">{errors.content}</p>}
            </div>

            <button type="submit" disabled={processing}>
                {processing ? '전송 중...' : '게시하기'}
            </button>
        </form>
    )
}
useForm이 반환하는 오브젝트의 주요 프로퍼티를 정리합니다.
프로퍼티 / 메서드설명
data폼의 데이터 오브젝트
setData(field, value)필드의 값 갱신
errors유효성 검증 오류 (필드명으로 접근)
processing송신 중에는 true (버튼 무효화에 사용)
isDirty초기값에서 변경되어 있는 경우 true
post(url)POST 요청으로 송신
put(url)PUT 요청으로 송신 (갱신)
delete(url)DELETE 요청으로 송신
reset()폼을 초기값으로 리셋
유효성 검증 오류가 반환되었을 때, useForm은 입력 내용을 유지한 채 오류를 표시합니다.

공유 데이터 (Shared Data)

모든 페이지에서 공통으로 필요한 데이터(로그인 중인 사용자 정보 · 플래시 메시지 등)는 HandleInertiaRequests 미들웨어의 share() 메서드로 정의합니다.
// app/Http/Middleware/HandleInertiaRequests.php
use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'auth' => [
                'user' => $request->user()
                    ? $request->user()->only('id', 'name', 'email')
                    : null,
            ],
            'flash' => [
                'success' => $request->session()->get('success'),
                'error'   => $request->session()->get('error'),
            ],
        ]);
    }
}
React 컴포넌트에서 공유 데이터에 접근하려면 usePage() 훅을 사용합니다.
import { usePage } from '@inertiajs/react'

type SharedProps = {
    auth: {
        user: { id: number; name: string; email: string } | null
    }
    flash: {
        success: string | null
        error: string | null
    }
}

export default function AppHeader() {
    const { auth, flash } = usePage<SharedProps>().props

    return (
        <>
            <header>
                {auth.user ? (
                    <span>{auth.user.name}</span>
                ) : (
                    <span>게스트</span>
                )}
            </header>

            {flash.success && (
                <div className="alert-success">{flash.success}</div>
            )}
        </>
    )
}
공유 데이터는 모든 요청에 포함되므로, 필요 최소한의 데이터로 좁힐 것을 권장합니다. fn()을 사용한 지연 평가로 하면, 실제로 접근되었을 때만 평가됩니다.

React 훅 기초

Inertia × React로 개발하는 데 있어 알아 두어야 할 React의 기본 훅을 소개합니다.

useState — 로컬 상태 관리

import { useState } from 'react'

export default function Counter() {
    const [count, setCount] = useState(0)
    const [isOpen, setIsOpen] = useState(false)

    return (
        <div>
            <p>{count}</p>
            <button onClick={() => setCount(count + 1)}>+1</button>
            <button onClick={() => setIsOpen(!isOpen)}>토글</button>
        </div>
    )
}

useEffect — 부수 효과 처리

import { useState, useEffect } from 'react'

export default function Timer() {
    const [seconds, setSeconds] = useState(0)

    useEffect(() => {
        const timer = setInterval(() => {
            setSeconds((s) => s + 1)
        }, 1000)

        // 클린업 함수
        return () => clearInterval(timer)
    }, []) // 빈 배열 = 마운트 시 한 번만 실행

    return <p>경과 시간: {seconds}</p>
}

useMemouseCallback — 퍼포먼스 최적화

import { useMemo, useCallback } from 'react'
import { router } from '@inertiajs/react'

type Post = { id: number; title: string; published: boolean }
type Props = { posts: Post[] }

export default function PostsList({ posts }: Props) {
    // 값을 메모이제이션 (posts가 바뀌지 않는 한 재계산하지 않음)
    const publishedPosts = useMemo(
        () => posts.filter((post) => post.published),
        [posts],
    )

    // 함수를 메모이제이션 (의존 값이 바뀌지 않는 한 재생성하지 않음)
    const handleClick = useCallback((id: number) => {
        router.visit(`/posts/${id}`)
    }, [])

    return (
        <ul>
            {publishedPosts.map((post) => (
                <li key={post.id} onClick={() => handleClick(post.id)}>
                    {post.title}
                </li>
            ))}
        </ul>
    )
}

TypeScript 지원

스타터 킷의 React 버전은 TypeScript가 기본입니다. Inertia의 타입 정의와 조합하면 props의 타입 안전을 확보할 수 있습니다.

전역 타입 정의

스타터 킷에서는 resources/js/types/index.d.ts에 공유 데이터의 타입을 정의합니다.
// resources/js/types/index.d.ts
export interface User {
    id: number
    name: string
    email: string
    email_verified_at?: string
}

export type PageProps<T extends Record<string, unknown> = Record<string, unknown>> = T & {
    auth: {
        user: User
    }
}

페이지 컴포넌트에서의 타입 이용

import { PageProps } from '@/types'

type Post = {
    id: number
    title: string
    content: string
}

export default function PostsIndex({ auth, posts }: PageProps<{ posts: Post[] }>) {
    return (
        <div>
            <p>로그인 중: {auth.user.name}</p>
            {posts.map((post) => (
                <article key={post.id}>
                    <h2>{post.title}</h2>
                </article>
            ))}
        </div>
    )
}

정리

React는 Laravel과의 조합에서, 특히 Inertia를 경유한 “모던 모놀리스” 구성에서 힘을 발휘합니다. TypeScript와의 궁합도 뛰어나며, 대규모 애플리케이션 개발에 적합합니다.
요소역할
Laravel 컨트롤러라우팅 · 데이터 취득 · 유효성 검증
Inertia::render()컨트롤러에서 React 컴포넌트로 데이터 전달
React 페이지 컴포넌트props를 받아 UI를 렌더링
useForm폼의 상태 관리 · 송신 · 오류 표시
Link 컴포넌트풀 리로드 없는 페이지 전환
usePage().props공유 데이터로의 접근
TypeScriptprops의 타입 안전 · IDE 자동완성 강화
Inertia × React를 사용하면, Laravel 백엔드의 심플함과 React의 강력한 에코시스템을 조합한 개발 경험을 얻을 수 있습니다. 스타터 킷으로 프로젝트를 생성하면 인증 화면도 포함해서 바로 개발을 시작할 수 있습니다.

Inertia.js 공식 문서

Inertia v3의 전 기능에 대해서는 공식 문서를 참고하세요.
마지막 수정일 2026년 7월 13일