Function Components

상태 없는 재사용 가능한 컴포넌트 함수


개요#

Function Components는 상태 관리가 필요 없는 간단한 UI 요소를 위한 경량 컴포넌트입니다. 일반 Component와 달리 WebSocket 연결이나 실시간 업데이트가 필요하지 않은 경우에 적합합니다.

사용 사례:

  • UI 프리미티브 (버튼, 아이콘, 배지)
  • 레이아웃 헬퍼 (카드, 그리드, 컨테이너)
  • 상태가 필요 없는 재사용 요소
from django.utils.html import format_html
from wireview import function_component

@function_component
def button(text: str, variant: str = "primary"):
    return format_html('<button class="btn btn-{}">{}</button>', variant, text)
{% load wireview %}
{% func "button" text="Click me" variant="danger" %}

기본 사용법#

1. 인라인 컴포넌트#

가장 간단한 형태로, 함수가 직접 HTML 문자열을 반환합니다.

반환한 문자열은 이스케이프 없이 그대로 출력됩니다. 이스케이프는 함수의 책임입니다. 인자를 f-string으로 끼워 넣으면 text="<script>…</script>"가 그대로 페이지에 들어갑니다(XSS). 값은 django.utils.html.format_html로 넣습니다 — 자리표시자 {}에 들어가는 값만 이스케이프하고, 이미 안전한 값(format_html의 결과, mark_safe)은 그대로 둡니다. 마크업이 길어지면 템플릿 기반 컴포넌트를 씁니다. 템플릿의 {{ }}는 자동으로 이스케이프됩니다.

# myapp/components.py
from django.utils.html import format_html
from wireview import function_component

@function_component
def icon(name: str, size: int = 24):
    """SVG 아이콘 컴포넌트."""
    return format_html(
        '<svg class="icon icon-{0}" width="{1}" height="{1}"><use href="#icon-{0}"></use></svg>',
        name,
        size,
    )

@function_component
def badge(text: str, color: str = "gray"):
    """상태 배지 컴포넌트."""
    return format_html('<span class="badge bg-{}">{}</span>', color, text)

템플릿에서 사용:

{% load wireview %}

{% func "icon" name="check" size=32 %}
{% func "badge" text="New" color="green" %}

2. 템플릿 기반 컴포넌트#

복잡한 HTML 구조는 템플릿 파일을 사용합니다.

# myapp/components.py
from wireview import function_component

@function_component(template="myapp/components/alert.html")
def alert(message: str, type: str = "info", dismissible: bool = False):
    """알림 메시지 컴포넌트."""
    return {
        "message": message,
        "type": type,
        "dismissible": dismissible,
    }
<!-- templates/myapp/components/alert.html -->
<div class="alert alert-{{ type }}{% if dismissible %} alert-dismissible{% endif %}" role="alert">
  {{ message }}
  {% if dismissible %}
    <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
  {% endif %}
</div>

템플릿에서 사용:

{% func "alert" message="저장되었습니다!" type="success" dismissible=True %}

템플릿에는 함수가 돌려준 dict가 넘어갑니다. 함수를 그리는 컴포넌트의 this와 필드는 넘어가지 않습니다. wireview_로 시작하는 키는 라이브러리가 쓰는 예약 이름이니 돌려주는 dict에 넣지 않습니다.

템플릿 안의 {% component %}#

함수 컴포넌트의 템플릿도 {% component %}로 상태 있는 컴포넌트를 그릴 수 있습니다. 라이브 렌더에서 그것은 바깥 템플릿에 직접 쓴 {% component %}와 같습니다 — 연결의 컴포넌트이고, 함수를 그린 컴포넌트가 그린 것으로 칩니다. 그래서 그 컴포넌트가 스스로 바뀐 뒤 함수를 그린 컴포넌트가 다시 렌더해도 바뀐 상태 그대로 그려집니다. 1.0 전에는 함수의 템플릿이 따로 그려서, 그런 렌더마다 템플릿 인자로 새로 만든 인스턴스가 화면을 덮었고 재연결하면 옛 상태로 join했습니다. 함수 컴포넌트 자체는 여전히 상태가 없습니다 — 상태는 그 안의 컴포넌트에 있습니다.

HTTP 응답(첫 화면)에서도 마찬가지로 페이지의 컴포넌트입니다. 요청의 사용자(self.user)·쿼리 파라미터· live_session 경계를 받고, 페이지의 on_mount 훅이 돕니다. 함수가 페이지의 첫 컴포넌트 태그여도 그렇습니다 — 페이지의 저장소는 컴포넌트가 처음 필요로 할 때 만들어지고 페이지의 나머지 태그가 함께 씁니다. 컴포넌트를 그리지 않는 함수만 쓰는 페이지에는 저장소가 생기지 않습니다. 페이지와 같은 저장소이므로 함수의 템플릿이 그리는 컴포넌트의 id도 페이지 전체에서 고유해야 합니다 — 페이지가 같은 id를 다른 클래스로 쓰면 요청이 StateMismatch로 실패합니다. 1.0 전에는 함수의 템플릿이 요청 없이 그려서 첫 화면의 self.user가 비어 있었고, Meta.live_sessions를 선언한 컴포넌트는 제 경계의 페이지에서도 거절되었으며, 페이지의 on_mount 훅이 거절하는 컴포넌트도 그려졌습니다. {% live_component %}는 부모 컴포넌트의 템플릿에서만 씁니다(함수의 템플릿에서는 TemplateSyntaxError).


슬롯 지원#

Function Components도 슬롯을 통해 콘텐츠를 주입받을 수 있습니다.

슬롯 정의#

@function_component(
    template="components/card.html",
    slots={
        "header": {"required": False, "doc": "카드 헤더"},
        "footer": {"required": False, "doc": "카드 푸터"},
    }
)
def card(title: str = "", variant: str = "default"):
    return {"title": title, "variant": variant}
<!-- templates/components/card.html -->
{% load wireview %}
<div class="card card-{{ variant }}">
  {% if slots.header %}
    <div class="card-header">{% render_slot "header" %}</div>
  {% elif title %}
    <div class="card-header">{{ title }}</div>
  {% endif %}

  <div class="card-body">
    {% render_slot %}
  </div>

  {% if slots.footer %}
    <div class="card-footer">{% render_slot "footer" %}</div>
  {% endif %}
</div>

슬롯 사용#

{% load wireview %}

{% func_block "card" variant="primary" %}
  {% fill header %}
    <h3>커스텀 헤더</h3>
  {% endfill %}

  <p>카드 본문 내용입니다.</p>

  {% fill footer %}
    <button class="btn btn-primary">저장</button>
  {% endfill %}
{% endfunc %}

필수 슬롯#

@function_component(
    template="components/modal.html",
    slots={
        "title": {"required": True, "doc": "모달 제목 - 필수"},
        "body": {"required": False},
        "actions": {"required": False},
    }
)
def modal(is_open: bool = False):
    return {"is_open": is_open}

필수 슬롯이 누락되면 명확한 에러 메시지가 표시됩니다:

TemplateSyntaxError: Function component 'modal' requires slot 'title' (모달 제목 - 필수).
Add: {% fill title %}...{% endfill %}

타입 변환#

템플릿에서 전달되는 값은 자동으로 타입 변환됩니다.

@function_component
def avatar(src: str, size: int = 40, rounded: bool = True):
    shape = "rounded-circle" if rounded else ""
    return format_html('<img src="{}" width="{}" class="avatar {}">', src, size, shape)
<!-- 문자열 "48"이 int 48로 변환됩니다 -->
{% func "avatar" src="/img/user.jpg" size="48" %}

<!-- 문자열 "false"가 bool False로 변환됩니다 -->
{% func "avatar" src="/img/user.jpg" rounded="false" %}

Bool 변환 규칙:

  • True: "true", "1", "yes", 비어있지 않은 문자열
  • False: "false", "0", "no", ""

정해 두지 않은 인자는 **kwargs가 받는다. 함수가 **attrs를 받으면 시그니처에 없는 인자가 변환 없이 그대로 거기로 간다. 받지 않으면 그런 인자는 TypeError다.

@function_component
def chip(text: str, **attrs):
    return format_html("<span {}>{}</span>", format_html_join(" ", '{}="{}"', attrs.items()), text)

컴포넌트 네이밍#

기본 이름#

함수 이름이 컴포넌트 이름이 됩니다:

@function_component
def my_button(text: str):
    return format_html("<button>{}</button>", text)

# 템플릿에서: {% func "my_button" text="Click" %}

커스텀 이름#

name 파라미터로 별도 이름 지정:

@function_component(name="btn")
def create_button(text: str):
    return format_html("<button>{}</button>", text)

# 템플릿에서: {% func "btn" text="Click" %}

모듈 경로#

이름 충돌 방지를 위해 모듈 경로 사용:

# myapp/components.py
@function_component(name="myapp.button")
def button(text: str):
    return format_html("<button>{}</button>", text)

# 템플릿에서: {% func "myapp.button" text="Click" %}

Component vs Function Component#

특성 Component Function Component
상태 관리 ✅ 있음 ❌ 없음
WebSocket ✅ 실시간 업데이트 ❌ 정적 렌더링
이벤트 핸들러 ✅ {% on %} 지원 ❌ 불가
슬롯 ✅ 지원 ✅ 지원
성능 무거움 (상태 직렬화) 가벼움
용도 인터랙티브 UI 정적 UI 조각

선택 가이드:

  • 사용자 입력에 반응해야 함 → Component
  • 서버 데이터를 실시간으로 표시 → Component
  • 단순 레이아웃/스타일링 → Function Component
  • 재사용 UI 조각 → Function Component

예제#

버튼 시스템#

@function_component
def button(
    text: str,
    variant: str = "primary",
    size: str = "md",
    disabled: bool = False,
    type: str = "button",
):
    classes = f"btn btn-{variant} btn-{size}"
    disabled_attr = " disabled" if disabled else ""
    return format_html('<button type="{}" class="{}"{}>{}</button>', type, classes, disabled_attr, text)
{% func "button" text="저장" variant="success" %}
{% func "button" text="삭제" variant="danger" disabled=True %}
{% func "button" text="제출" type="submit" %}

아이콘 버튼#

@function_component
def icon_button(icon: str, text: str = "", variant: str = "secondary"):
    icon_html = format_html('<i class="bi bi-{}"></i>', icon)
    text_html = format_html(" <span>{}</span>", text) if text else ""
    # format_html의 결과는 안전한 문자열이라 바깥 format_html이 다시 이스케이프하지 않는다
    return format_html('<button class="btn btn-{}">{}{}</button>', variant, icon_html, text_html)
{% func "icon_button" icon="trash" text="삭제" variant="danger" %}
{% func "icon_button" icon="plus" %}  <!-- 아이콘만 -->

리스트 그룹#

@function_component(
    template="components/list_group.html",
    slots={"item": {"required": True, "doc": "각 아이템 템플릿"}}
)
def list_group(items: list, bordered: bool = True):
    return {"items": items, "bordered": bordered}
<!-- templates/components/list_group.html -->
{% load wireview %}
<ul class="list-group{% if bordered %} list-group-bordered{% endif %}">
  {% for item in items %}
    <li class="list-group-item">
      {% render_slot "item" item=item index=forloop.counter %}
    </li>
  {% endfor %}
</ul>
{% load wireview humanize %}  {# intcomma: django.contrib.humanize가 INSTALLED_APPS에 있어야 한다 #}
{% func_block "list_group" items=products %}
  {% fill item let:item let:index %}
    <span class="badge">{{ index }}</span>
    {{ item.name }} - {{ item.price|intcomma }}원
  {% endfill %}
{% endfunc %}

API 레퍼런스#

@function_component 데코레이터#

def function_component(
    func=None,                    # 인자 없이 @function_component로 쓸 때의 함수
    *,
    name: str | None = None,      # 컴포넌트 이름 (기본: 함수 이름)
    template: str | None = None,  # 템플릿 경로
    slots: dict | None = None,    # 슬롯 정의
): ...

Template Tags#

{% func %}#

심플 태그 - 슬롯 없이 렌더링:

{% func "name" arg1=value1 arg2=value2 %}

{% func_block %}#

블록 태그 - 슬롯 지원:

{% func_block "name" arg1=value1 %}
  {% fill slotname %}...{% endfill %}
  기본 슬롯 내용
{% endfunc %}

Python API#

from wireview import get_function_component

fc = get_function_component("button")
html = fc.render({"text": "Click", "variant": "primary"})

# 인수 검증
validated = fc.validate_args({"text": "Click"})

참고#


마지막 업데이트: 2026-10-01