January 5, 20265 minutes
최근 ‘리믹스(Remix)’ 팀이 개발 중인 @remix-run/component를 직접 사용해 보고 깊이 파헤쳐 봤는데요.
리액트 생태계에 익숙한 우리에게는 ‘훅(Hooks)’ 없는 컴포넌트라는 개념이 다소 낯설면서도 신선하게 다가옵니다.
이 라이브러리는 리액트 라우터(React Router) v7과는 또 다른 접근 방식으로, 리액트 의존성을 걷어내고 순수 자바스크립트와 DOM 프리미티브(Primitives)에 집중한 독자적인 모델을 제시하고 있습니다.
이번 글에서는 공식 문서와 README 내용을 바탕으로 기본 튜토리얼부터 심화 기능까지, 이 새로운 라이브러리를 어떻게 다뤄야 하는지 완벽하게 정리해 드립니다.
이 라이브러리는 리믹스 팀이 실험적으로 선보인 경량 컴포넌트 시스템인데요.
가장 큰 특징은 훅을 전혀 사용하지 않고, 자바스크립트의 기본 기능인 ‘변수’와 ‘클로저’만으로 상태를 관리한다는 점입니다.
또한 브라우저의 웹 표준 API(EventTarget, AbortSignal 등)를 적극적으로 활용하여 불필요한 추상화를 줄였습니다.
우선 라이브러리를 설치하는 방법부터 알아볼까요?
npm install @remix-run/component설치가 끝났다면 가장 기본적인 ‘Hello World’ 격인 카운터 앱을 만들어 보겠습니다.
리액트의 ReactDOM.createRoot와 유사한 방식으로 진입점을 설정합니다.
import { createRoot, type Handle } from '@remix-run/component'
function App(this: Handle) {
let count = 0
return () => (
<button
on={{
click: () => {
count++
this.update()
},
}}
>
Count: {count}
</button>
)
}
createRoot(document.body).render(<App />)코드를 보면 this: Handle을 통해 컴포넌트의 컨텍스트에 접근하고, count 변수를 직접 수정하는 것을 볼 수 있는데요.
상태 변경 후에는 this.update()를 호출하여 명시적으로 재렌더링을 예약해야 한다는 점이 리액트와의 결정적인 차이입니다.
리액트와 가장 큰 차이점은 컴포넌트가 실행되는 방식, 즉 ‘멘탈 모델’이 완전히 다르다는 것인데요.
리액트 컴포넌트는 재렌더링 될 때마다 함수 전체가 다시 실행되지만, @remix-run/component는 초기화 시점에 딱 한 번만 실행됩니다.
단순히 JSX를 반환하는 컴포넌트는 상태를 유지하지 않습니다.
부모가 업데이트될 때마다 이 함수도 매번 새로 실행되므로, 스코프가 초기화됩니다.
function Greeting(this: Handle, props: { name: string }) {
return <h1>Hello, {props.name}!</h1>
}값을 기억해야 한다면 함수 내부에서 ‘렌더 함수’를 반환(return)하는 구조를 취해야 합니다.
이때 컴포넌트는 두 가지 페이즈로 나뉘게 됩니다.
셋업(Setup) 페이즈: 바깥쪽 함수가 실행되는 단계로, 컴포넌트 생성 시 단 1회만 실행됩니다. 여기서 변수(상태)를 초기화하고 이벤트 리스너를 등록합니다.
렌더(Render) 페이즈: 반환된 내부 함수가 실행되는 단계로, update()가 호출될 때마다 실행됩니다.
function Counter(this: Handle, setupProps: { initial: number }) {
// [셋업 페이즈] 1회 실행
let count = setupProps.initial
// [렌더 페이즈] 매 업데이트마다 실행
return (renderProps: { label?: string }) => (
<div>
{renderProps.label || 'Count'}: {count}
<button
on={{
click: () => {
count++
this.update()
},
}}
>
Increment
</button>
</div>
)
}이 구조 덕분에 우리는 useState나 useEffect 같은 훅 없이도, 단순히 변수 count를 클로저에 가둬두는 것만으로 상태를 유지할 수 있는 것입니다.
위 예제에서 보셨듯이 프롭스를 받는 위치도 중요한데요.
초기화에만 필요한 값은 바깥쪽 함수의 인자(setupProps)로 받고, 시간이 지남에 따라 변할 수 있는 값은 안쪽 함수의 인자(renderProps)로 받아야 합니다.
보통 initial이나 defaultValue 같은 설정값은 셋업 프롭스로, label이나 disabled 같은 UI 속성은 렌더 프롭스로 처리합니다.
이벤트 핸들링은 on 프롭을 사용하며, @remix-run/interaction 라이브러리가 내부적으로 DOM 이벤트를 처리해 주는데요.
여기서 아주 강력한 기능이 하나 등장합니다.
바로 이벤트 핸들러의 두 번째 인자로 전달되는 AbortSignal입니다.
function SearchInput(this: Handle) {
let query = ''
return () => (
<input
type="text"
value={query}
on={{
input: (event, signal) => {
query = event.currentTarget.value
this.update()
// 비동기 통신 시 경쟁 상태(Race Condition) 방지
fetch(`/search?q=${query}`, { signal })
.then((res) => res.json())
.then((results) => {
if (signal.aborted) return
// 결과 업데이트 로직
})
},
}}
/>
)
}사용자가 빠르게 타이핑할 때 이전 요청을 자동으로 취소하거나, 컴포넌트가 사라질 때 메모리 누수를 방지하는 코드를 별도의 useEffect 없이 이벤트 핸들러 내부에서 직관적으로 작성할 수 있습니다.
window나 document 같은 전역 객체의 이벤트도 this.on() 메서드를 사용하면 아주 깔끔하게 처리할 수 있는데요.
컴포넌트가 연결 해제(Unmount)될 때 자동으로 리스너를 정리(Cleanup)해 주기 때문에 개발자가 신경 쓸 부분이 확 줄어듭니다.
function KeyboardTracker(this: Handle) {
let keys: string[] = []
this.on(document, {
keydown: (event) => {
keys.push(event.key)
this.update()
},
})
return () => <div>Keys: {keys.join(', ')}</div>
}스타일링은 css 프롭을 통해 인라인 스타일 객체로 정의하는데요.
단순한 인라인 스타일을 넘어, 중첩(Nesting) 구문이나 의사 선택자(Pseudo-selectors), 미디어 쿼리까지 지원하는 강력한 기능을 내장하고 있습니다.
function Button(this: Handle) {
return () => (
<button
css={{
color: 'white',
backgroundColor: 'blue',
'&:hover': {
backgroundColor: 'darkblue',
},
'@media (max-width: 768px)': {
padding: '8px',
},
}}
>
Click me
</button>
)
}DOM 요소에 직접 접근해야 할 때는 리액트의 ref 대신 connect 프롭을 사용합니다.
이 콜백 역시 AbortSignal을 제공하므로, ResizeObserver 같은 API를 연결하고 해제하는 작업이 매우 수월해집니다.
function Component(this: Handle) {
return () => (
<div
connect={(node, signal) => {
let observer = new ResizeObserver(() => { /* ... */ })
observer.observe(node)
// 요소가 DOM에서 제거될 때 자동 실행
signal.addEventListener('abort', () => {
observer.disconnect()
})
}}
>
Content
</div>
)
}화면을 그린 직후에 포커스를 이동하거나 스크롤을 해야 한다면 this.queueTask()를 활용하세요.
이 메서드는 다음 업데이트가 완료된 직후에 실행될 작업을 예약해 줍니다.
컨텍스트(Context) API 또한 제공되는데요.
다만, 컨텍스트 값을 변경한다고 해서 하위 컴포넌트가 자동으로 재렌더링 되지는 않습니다.
이 문제를 해결하기 위한 리믹스 팀의 권장 패턴은 컨텍스트 값 자체를 EventTarget으로 만드는 것입니다.
// 1. EventTarget을 상속받은 상태 클래스 정의
class Theme extends TypedEventTarget<{ change: Event }> {
#value = 'light'
get value() { return this.#value }
setValue(val) {
this.#value = val
this.dispatchEvent(new Event('change')) // 변경 알림 발송
}
}
// 2. 소비하는 컴포넌트에서 구독
function ThemedContent(this: Handle) {
let theme = this.context.get(App)
// 변경 이벤트 구독 -> 업데이트 트리거
this.on(theme, { change: () => this.update() })
return () => (
<div css={{ backgroundColor: theme.value === 'dark' ? '#000' : '#fff' }}>
Current theme: {theme.value}
</div>
)
}이렇게 하면 값이 바뀔 때 필요한 부분만 정확하게 업데이트할 수 있어 성능 최적화에도 유리합니다.
지금까지 @remix-run/component의 핵심 기능들을 훑어보았는데요.
리액트의 방대한 생태계와 훅의 편리함도 좋지만, 클로저와 웹 표준 API만으로 이렇게 깔끔하게 컴포넌트를 구성할 수 있다는 점이 매우 인상적입니다.
물론 아직 SSR이나 비동기 컴포넌트 미지원 등 제약 사항이 있어 실무에 바로 투입하기엔 이르지만, 리믹스 팀이 그리는 ‘웹 표준 중심의 미래’를 엿볼 수 있는 좋은 기회였습니다.
여러분도 가볍게 사이드 프로젝트로 이 새로운 패러다임을 경험해 보시는 건 어떨까요?