diff --git a/notebooks/introduction.ipynb b/notebooks/introduction.ipynb
index 304c932..22b3360 100644
--- a/notebooks/introduction.ipynb
+++ b/notebooks/introduction.ipynb
@@ -182,7 +182,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Let's consider a ReactPy component that responds to and displays changes from an `ipywidgets.IntSlider`. The ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, declare state that will track the slider's value, and register a lister that will update that state via the slider's `IntSlider.observe()` method using an [\"effect\"](https://reactpy.dev/docs/reference/hooks-api.html#use-effect):"
+    "Let's consider a ReactPy component that mirrors an `ipywidgets.IntSlider` - that is, it displays a slider that moves when the `IntSlider` does and when moved alters the `IntSlider`. To accomplish this, the ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, and access the attributes it expects to change or that need to be changed via a `use_trait` method on the converted widget:"
    ]
   },
   {
@@ -193,27 +193,26 @@
    },
    "outputs": [],
    "source": [
-    "from reactpy import use_effect\n",
     "from reactpy_jupyter import from_widget\n",
     "\n",
     "\n",
     "@component\n",
-    "def SliderObserver(slider):\n",
-    "    slider_component = from_widget(slider)\n",
-    "    value, set_value = use_state(0)\n",
-    "\n",
-    "    @use_effect\n",
-    "    def register_observer():\n",
-    "        def handle_change(change):\n",
-    "            set_value(change[\"new\"])\n",
-    "\n",
-    "        # observe the slider's value\n",
-    "        slider.observe(handle_change, \"value\")\n",
-    "        # unobserve the slider's value if this component is no longer displayed\n",
-    "        return lambda: slider.unobserve(handle_change, \"value\")\n",
-    "\n",
+    "def MirrorSlider(slider_widget):\n",
+    "    slider_component = from_widget(slider_widget)\n",
+    "    value, set_value = slider_component.use_trait(\"value\")\n",
     "    return html.div(\n",
-    "        slider_component, html.p(f\"ReactPy observes the value to be: \", value)\n",
+    "        html.h3(\"Jupyter Slider\"),\n",
+    "        # slider_component,\n",
+    "        html.h3(\"ReactPy Slider\"),\n",
+    "        html.input(\n",
+    "            {\n",
+    "                \"type\": \"range\",\n",
+    "                \"min\": slider_widget.min,\n",
+    "                \"max\": slider_widget.max,\n",
+    "                \"value\": value,\n",
+    "                \"on_change\": lambda event: set_value(event[\"target\"][\"value\"]),\n",
+    "            }\n",
+    "        ),\n",
     "    )"
    ]
   },
@@ -235,7 +234,7 @@
    "source": [
     "from ipywidgets import IntSlider\n",
     "\n",
-    "SliderObserver(IntSlider(readout=False))"
+    "MirrorSlider(IntSlider(readout=False))"
    ]
   },
   {
@@ -258,9 +257,9 @@
     "from reactpy_jupyter import to_widget\n",
     "\n",
     "slider = IntSlider(readout=False)\n",
-    "slider_observer_widget = to_widget(SliderObserver(slider))\n",
+    "slider_observer_widget = to_widget(MirrorSlider(slider))\n",
     "\n",
-    "Box([slider, slider_observer_widget])"
+    "Box([slider_observer_widget, slider_observer_widget])"
    ]
   },
   {
diff --git a/reactpy_jupyter/__init__.py b/reactpy_jupyter/__init__.py
index 60ea3a6..098894d 100644
--- a/reactpy_jupyter/__init__.py
+++ b/reactpy_jupyter/__init__.py
@@ -5,8 +5,9 @@
 # Distributed under the terms of the Modified BSD License.
 
 from . import jupyter_server_extension
+from .component_widget import run, set_import_source_base_url, to_widget
+from .hooks import use_trait
 from .import_resources import setup_import_resources
-from .layout_widget import run, set_import_source_base_url, to_widget
 from .monkey_patch import execute_patch
 from .widget_component import from_widget
 
@@ -14,12 +15,13 @@
 
 __all__ = (
     "from_widget",
+    "jupyter_server_extension",
     "load_ipython_extension",
-    "unload_ipython_extension",
-    "to_widget",
     "run",
     "set_import_source_base_url",
-    "jupyter_server_extension",
+    "to_widget",
+    "unload_ipython_extension",
+    "use_trait",
 )
 
 
diff --git a/reactpy_jupyter/layout_widget.py b/reactpy_jupyter/component_widget.py
similarity index 92%
rename from reactpy_jupyter/layout_widget.py
rename to reactpy_jupyter/component_widget.py
index 4a7de1a..52d6d85 100644
--- a/reactpy_jupyter/layout_widget.py
+++ b/reactpy_jupyter/component_widget.py
@@ -35,38 +35,38 @@ def run(constructor: Callable[[], ComponentType]) -> DisplayHandle | None:
 
     This function is meant to be similarly to ``reactpy.run``.
     """
-    return ipython_display(LayoutWidget(constructor()))
+    return ipython_display(ComponentWidget(constructor()))
 
 
 _P = ParamSpec("_P")
 
 
 @overload
-def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]:
+def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, ComponentWidget]:
     ...
 
 
 @overload
-def to_widget(value: ComponentType) -> LayoutWidget:
+def to_widget(value: ComponentType) -> ComponentWidget:
     ...
 
 
 def to_widget(
     value: Callable[_P, ComponentType] | ComponentType
-) -> Callable[_P, LayoutWidget] | LayoutWidget:
+) -> Callable[_P, ComponentWidget] | ComponentWidget:
     """Turn a component into a widget or a component construtor into a widget constructor"""
 
     if isinstance(value, ComponentType):
-        return LayoutWidget(value)
+        return ComponentWidget(value)
 
     @wraps(value)
-    def wrapper(*args: Any, **kwargs: Any) -> LayoutWidget:
-        return LayoutWidget(value(*args, **kwargs))
+    def wrapper(*args: Any, **kwargs: Any) -> ComponentWidget:
+        return ComponentWidget(value(*args, **kwargs))
 
     return wrapper
 
 
-class LayoutWidget(anywidget.AnyWidget):
+class ComponentWidget(anywidget.AnyWidget):
     """A widget for displaying ReactPy elements"""
 
     _esm = ESM
diff --git a/reactpy_jupyter/hooks.py b/reactpy_jupyter/hooks.py
new file mode 100644
index 0000000..0356607
--- /dev/null
+++ b/reactpy_jupyter/hooks.py
@@ -0,0 +1,28 @@
+from typing import Any
+
+from reactpy import use_effect, use_state
+from reactpy.types import State
+from traitlets import HasTraits
+
+
+def use_trait(obj: HasTraits, name: str) -> State[Any]:
+    """Hook to use the attribute of a HasTraits object as a state variable
+
+    This works on Jupyter Widgets, for example.
+    """
+    value, set_value = use_state(lambda: getattr(obj, name))
+
+    @use_effect
+    def register_observer():
+        def handle_change(change):
+            set_value(change["new"])
+
+        # observe the slider's value
+        obj.observe(handle_change, "value")
+        # unobserve the slider's value if this component is no longer displayed
+        return lambda: obj.unobserve(handle_change, "value")
+
+    def set_trait(new_value: Any) -> None:
+        setattr(obj, name, new_value)
+
+    return State(value, set_trait)
diff --git a/reactpy_jupyter/import_resources.py b/reactpy_jupyter/import_resources.py
index e57d0bb..642f368 100644
--- a/reactpy_jupyter/import_resources.py
+++ b/reactpy_jupyter/import_resources.py
@@ -10,11 +10,11 @@
 import requests
 from notebook import notebookapp
 
+from .component_widget import set_import_source_base_url
 from .jupyter_server_extension import (
     REACTPY_RESOURCE_BASE_PATH,
     REACTPY_WEB_MODULES_DIR,
 )
-from .layout_widget import set_import_source_base_url
 
 logger = logging.getLogger(__name__)
 
diff --git a/reactpy_jupyter/monkey_patch.py b/reactpy_jupyter/monkey_patch.py
index a60452e..1ffdf34 100644
--- a/reactpy_jupyter/monkey_patch.py
+++ b/reactpy_jupyter/monkey_patch.py
@@ -1,25 +1,8 @@
-from typing import Any
-from weakref import finalize
-
 from reactpy.core.component import Component
 
-from reactpy_jupyter.layout_widget import to_widget
-
-# we can't track the widgets by adding them as a hidden attribute to the component
-# because Component has __slots__ defined
-LIVE_WIDGETS: dict[int, Any] = {}
+from reactpy_jupyter.widget_component import WidgetComponent
 
 
 def execute_patch() -> None:
     """Monkey patch ReactPy's Component class to display as a Jupyter widget"""
-
-    def _repr_mimebundle_(self: Component, *a, **kw) -> None:
-        self_id = id(self)
-        if self_id not in LIVE_WIDGETS:
-            widget = LIVE_WIDGETS[self_id] = to_widget(self)
-            finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None))
-        else:
-            widget = LIVE_WIDGETS[self_id]
-        return widget._repr_mimebundle_(*a, **kw)
-
-    Component._repr_mimebundle_ = _repr_mimebundle_
+    Component._repr_mimebundle_ = WidgetComponent._repr_mimebundle_
diff --git a/reactpy_jupyter/widget_component.py b/reactpy_jupyter/widget_component.py
index fd96971..61550e0 100644
--- a/reactpy_jupyter/widget_component.py
+++ b/reactpy_jupyter/widget_component.py
@@ -1,28 +1,61 @@
 from __future__ import annotations
 
-from typing import Callable
+from typing import Any, Callable
+from weakref import finalize
 
 from attr import dataclass
 from ipywidgets import Widget
-from reactpy import component, create_context, html, use_context, use_effect
-from reactpy.types import Context, VdomDict
+from reactpy import create_context, html, use_context, use_effect
+from reactpy.types import Context, Key, State, VdomDict
+
+import reactpy_jupyter
+from reactpy_jupyter.hooks import use_trait as _use_trait
+
+# we can't track the widgets by adding them as a hidden attribute to the component
+# because Component has __slots__ defined
+LIVE_WIDGETS: dict[int, Any] = {}
 
 inner_widgets_context: Context[InnerWidgets | None] = create_context(None)
 
 
-@component
-def from_widget(source: Widget) -> VdomDict:
-    inner_widgets = use_context(inner_widgets_context)
+def from_widget(source: Widget, key: Key | None = None) -> WidgetComponent:
+    return WidgetComponent(source, key)
+
+
+class WidgetComponent:
+    """implements reactpy.types.ComponentType"""
+
+    def __init__(self, widget: Widget, key: Key | None) -> None:
+        self.widget = widget
+        self.type = type(widget)
+        self.key = key
+
+    def use_trait(self, name: str) -> State[Any]:
+        return _use_trait(self.widget, name)
+
+    def render(self) -> VdomDict:
+        inner_widgets = use_context(inner_widgets_context)
+
+        @use_effect
+        def add_widget():
+            inner_widgets.add(self.widget)
+            return lambda: inner_widgets.remove(self.widget)
 
-    @use_effect
-    def add_widget():
-        inner_widgets.add(source)
-        return lambda: inner_widgets.remove(source)
+        if inner_widgets is None:
+            raise RuntimeError(
+                "Jupyter component must be rendered inside a JupyterLayout"
+            )
 
-    if inner_widgets is None:
-        raise RuntimeError("Jupyter component must be rendered inside a JupyterLayout")
+        return html.span({"class": f"widget-model-id-{self.widget.model_id}"})
 
-    return html.span({"class": f"widget-model-id-{source.model_id}"})
+    def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> None:
+        self_id = id(self)
+        if self_id not in LIVE_WIDGETS:
+            widget = LIVE_WIDGETS[self_id] = reactpy_jupyter.to_widget(self)
+            finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None))
+        else:
+            widget = LIVE_WIDGETS[self_id]
+        return widget._repr_mimebundle_(*args, **kwargs)
 
 
 @dataclass