diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml
index b312869e4..1b21e4202 100644
--- a/.github/workflows/.hatch-run.yml
+++ b/.github/workflows/.hatch-run.yml
@@ -1,59 +1,59 @@
 name: hatch-run
 
 on:
-  workflow_call:
-    inputs:
-      job-name:
-        required: true
-        type: string
-      hatch-run:
-        required: true
-        type: string
-      runs-on-array:
-        required: false
-        type: string
-        default: '["ubuntu-latest"]'
-      python-version-array:
-        required: false
-        type: string
-        default: '["3.x"]'
-      node-registry-url:
-        required: false
-        type: string
-        default: ""
-    secrets:
-      node-auth-token:
-        required: false
-      pypi-username:
-        required: false
-      pypi-password:
-        required: false
+    workflow_call:
+        inputs:
+            job-name:
+                required: true
+                type: string
+            hatch-run:
+                required: true
+                type: string
+            runs-on-array:
+                required: false
+                type: string
+                default: '["ubuntu-latest"]'
+            python-version-array:
+                required: false
+                type: string
+                default: '["3.x"]'
+            node-registry-url:
+                required: false
+                type: string
+                default: ""
+        secrets:
+            node-auth-token:
+                required: false
+            pypi-username:
+                required: false
+            pypi-password:
+                required: false
 
 jobs:
-  hatch:
-    name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
-    strategy:
-      matrix:
-        python-version: ${{ fromJson(inputs.python-version-array) }}
-        runs-on: ${{ fromJson(inputs.runs-on-array) }}
-    runs-on: ${{ matrix.runs-on }}
-    steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-node@v2
-        with:
-          node-version: "14.x"
-          registry-url: ${{ inputs.node-registry-url }}
-      - name: Pin NPM Version
-        run: npm install -g npm@8.19.3
-      - name: Use Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v2
-        with:
-          python-version: ${{ matrix.python-version }}
-      - name: Install Python Dependencies
-        run: pip install hatch poetry
-      - name: Run Scripts
-        env:
-          NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
-          PYPI_USERNAME: ${{ secrets.pypi-username }}
-          PYPI_PASSWORD: ${{ secrets.pypi-password }}
-        run: hatch run ${{ inputs.hatch-run }}
+    hatch:
+        name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
+        strategy:
+            matrix:
+                python-version: ${{ fromJson(inputs.python-version-array) }}
+                runs-on: ${{ fromJson(inputs.runs-on-array) }}
+        runs-on: ${{ matrix.runs-on }}
+        steps:
+            - uses: actions/checkout@v2
+            - uses: actions/setup-node@v2
+              with:
+                  node-version: "14.x"
+                  registry-url: ${{ inputs.node-registry-url }}
+            - name: Pin NPM Version
+              run: npm install -g npm@8.19.3
+            - name: Use Python ${{ matrix.python-version }}
+              uses: actions/setup-python@v2
+              with:
+                  python-version: ${{ matrix.python-version }}
+            - name: Install Python Dependencies
+              run: pip install hatch poetry
+            - name: Run Scripts
+              env:
+                  NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }}
+                  PYPI_USERNAME: ${{ secrets.pypi-username }}
+                  PYPI_PASSWORD: ${{ secrets.pypi-password }}
+              run: hatch run ${{ inputs.hatch-run }}
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index af768579c..d370ea129 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -1,45 +1,48 @@
 name: check
 
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    branches:
-      - main
-  schedule:
-    - cron: "0 0 * * 0"
+    push:
+        branches:
+            - main
+    pull_request:
+        branches:
+            - main
+    schedule:
+        - cron: "0 0 * * 0"
 
 jobs:
-  test-py-cov:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "python-{0}"
-      hatch-run: "test-py"
-  lint-py:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "python-{0}"
-      hatch-run: "lint-py"
-  test-py-matrix:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "python-{0} {1}"
-      hatch-run: "test-py --no-cov"
-      runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]'
-      python-version-array: '["3.9", "3.10", "3.11"]'
-  test-docs:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "python-{0}"
-      hatch-run: "test-docs"
-  test-js:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "{1}"
-      hatch-run: "test-js"
-  lint-js:
-    uses: ./.github/workflows/.hatch-run.yml
-    with:
-      job-name: "{1}"
-      hatch-run: "lint-js"
+    test-py-cov:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "python-{0}"
+            hatch-run: "test-py"
+    lint-py:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "python-{0}"
+            hatch-run: "lint-py"
+    test-py-matrix:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "python-{0} {1}"
+            hatch-run: "test-py --no-cov"
+            runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]'
+            python-version-array: '["3.9", "3.10", "3.11"]'
+    test-docs:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "python-{0}"
+            hatch-run: "test-docs"
+            # as of Dec 2023 lxml does have wheels for 3.12
+            # https://bugs.launchpad.net/lxml/+bug/2040440
+            python-version-array: '["3.11"]'
+    test-js:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "{1}"
+            hatch-run: "test-js"
+    lint-js:
+        uses: ./.github/workflows/.hatch-run.yml
+        with:
+            job-name: "{1}"
+            hatch-run: "lint-js"
diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst
index 32a3df2dc..d874a470f 100644
--- a/docs/source/about/changelog.rst
+++ b/docs/source/about/changelog.rst
@@ -28,6 +28,12 @@ Unreleased
 - :pull:`1118` - `module_from_template` is broken with a recent release of `requests`
 - :pull:`1131` - `module_from_template` did not work when using Flask backend
 
+**Added**
+
+- :pull:`1165` - Allow concurrent renders of discrete component tree - enable this
+  experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This should improve
+  the overall responsiveness of your app, particularly when handling larger renders
+  that would otherwise block faster renders from being processed.
 
 v1.0.2
 ------
diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml
index 87fa7e036..67189808b 100644
--- a/src/py/reactpy/pyproject.toml
+++ b/src/py/reactpy/pyproject.toml
@@ -45,6 +45,8 @@ starlette = [
 sanic = [
   "sanic >=21",
   "sanic-cors",
+  "tracerite>=1.1.1",
+  "setuptools",
   "uvicorn[standard] >=0.19.0",
 ]
 fastapi = [
@@ -80,7 +82,7 @@ pre-install-command = "hatch build --hooks-only"
 dependencies = [
   "coverage[toml]>=6.5",
   "pytest",
-  "pytest-asyncio>=0.17",
+  "pytest-asyncio>=0.23",
   "pytest-mock",
   "pytest-rerunfailures",
   "pytest-timeout",
diff --git a/src/py/reactpy/reactpy/_option.py b/src/py/reactpy/reactpy/_option.py
index 09d0304a9..1db0857e3 100644
--- a/src/py/reactpy/reactpy/_option.py
+++ b/src/py/reactpy/reactpy/_option.py
@@ -68,6 +68,10 @@ def current(self) -> _O:
     def current(self, new: _O) -> None:
         self.set_current(new)
 
+    @current.deleter
+    def current(self) -> None:
+        self.unset()
+
     def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]:
         """Register a callback that will be triggered when this option changes"""
         if not self.mutable:
@@ -123,7 +127,8 @@ def unset(self) -> None:
             msg = f"{self} cannot be modified after initial load"
             raise TypeError(msg)
         old = self.current
-        delattr(self, "_current")
+        if hasattr(self, "_current"):
+            delattr(self, "_current")
         if self.current != old:
             for sub_func in self._subscribers:
                 sub_func(self.current)
diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py
index 19ad114ed..ee4ce1b5c 100644
--- a/src/py/reactpy/reactpy/backend/hooks.py
+++ b/src/py/reactpy/reactpy/backend/hooks.py
@@ -4,7 +4,8 @@
 from typing import Any
 
 from reactpy.backend.types import Connection, Location
-from reactpy.core.hooks import Context, create_context, use_context
+from reactpy.core.hooks import create_context, use_context
+from reactpy.core.types import Context
 
 # backend implementations should establish this context at the root of an app
 ConnectionContext: Context[Connection[Any] | None] = create_context(None)
diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py
index 8371e6d08..8ea6aed03 100644
--- a/src/py/reactpy/reactpy/config.py
+++ b/src/py/reactpy/reactpy/config.py
@@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool:
     validator=float,
 )
 """A default timeout for testing utilities in ReactPy"""
+
+REACTPY_ASYNC_RENDERING = Option(
+    "REACTPY_CONCURRENT_RENDERING",
+    default=False,
+    mutable=True,
+    validator=boolean,
+)
+"""Whether to render components concurrently. This is currently an experimental feature."""
diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py
new file mode 100644
index 000000000..ea5e6d634
--- /dev/null
+++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import logging
+from asyncio import Event, Task, create_task, gather
+from typing import Any, Callable, Protocol, TypeVar
+
+from anyio import Semaphore
+
+from reactpy.core._thread_local import ThreadLocal
+from reactpy.core.types import ComponentType, Context, ContextProviderType
+
+T = TypeVar("T")
+
+
+class EffectFunc(Protocol):
+    async def __call__(self, stop: Event) -> None:
+        ...
+
+
+logger = logging.getLogger(__name__)
+
+_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
+
+
+def current_hook() -> LifeCycleHook:
+    """Get the current :class:`LifeCycleHook`"""
+    hook_stack = _HOOK_STATE.get()
+    if not hook_stack:
+        msg = "No life cycle hook is active. Are you rendering in a layout?"
+        raise RuntimeError(msg)
+    return hook_stack[-1]
+
+
+class LifeCycleHook:
+    """An object which manages the "life cycle" of a layout component.
+
+    The "life cycle" of a component is the set of events which occur from the time
+    a component is first rendered until it is removed from the layout. The life cycle
+    is ultimately driven by the layout itself, but components can "hook" into those
+    events to perform actions. Components gain access to their own life cycle hook
+    by calling :func:`current_hook`. They can then perform actions such as:
+
+    1. Adding state via :meth:`use_state`
+    2. Adding effects via :meth:`add_effect`
+    3. Setting or getting context providers via
+       :meth:`LifeCycleHook.set_context_provider` and
+       :meth:`get_context_provider` respectively.
+
+    Components can request access to their own life cycle events and state through hooks
+    while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
+    forward by triggering events and rendering view changes.
+
+    Example:
+
+        If removed from the complexities of a layout, a very simplified full life cycle
+        for a single component with no child components would look a bit like this:
+
+        .. testcode::
+
+            from reactpy.core._life_cycle_hook import LifeCycleHook
+            from reactpy.core.hooks import current_hook
+
+            # this function will come from a layout implementation
+            schedule_render = lambda: ...
+
+            # --- start life cycle ---
+
+            hook = LifeCycleHook(schedule_render)
+
+            # --- start render cycle ---
+
+            component = ...
+            await hook.affect_component_will_render(component)
+            try:
+                # render the component
+                ...
+
+                # the component may access the current hook
+                assert current_hook() is hook
+
+                # and save state or add effects
+                current_hook().use_state(lambda: ...)
+
+                async def my_effect(stop_event):
+                    ...
+
+                current_hook().add_effect(my_effect)
+            finally:
+                await hook.affect_component_did_render()
+
+            # This should only be called after the full set of changes associated with a
+            # given render have been completed.
+            await hook.affect_layout_did_render()
+
+            # Typically an event occurs and a new render is scheduled, thus beginning
+            # the render cycle anew.
+            hook.schedule_render()
+
+
+            # --- end render cycle ---
+
+            hook.affect_component_will_unmount()
+            del hook
+
+            # --- end render cycle ---
+    """
+
+    __slots__ = (
+        "__weakref__",
+        "_context_providers",
+        "_current_state_index",
+        "_effect_funcs",
+        "_effect_stops",
+        "_effect_tasks",
+        "_render_access",
+        "_rendered_atleast_once",
+        "_schedule_render_callback",
+        "_scheduled_render",
+        "_state",
+        "component",
+    )
+
+    component: ComponentType
+
+    def __init__(
+        self,
+        schedule_render: Callable[[], None],
+    ) -> None:
+        self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {}
+        self._schedule_render_callback = schedule_render
+        self._scheduled_render = False
+        self._rendered_atleast_once = False
+        self._current_state_index = 0
+        self._state: tuple[Any, ...] = ()
+        self._effect_funcs: list[EffectFunc] = []
+        self._effect_tasks: list[Task[None]] = []
+        self._effect_stops: list[Event] = []
+        self._render_access = Semaphore(1)  # ensure only one render at a time
+
+    def schedule_render(self) -> None:
+        if self._scheduled_render:
+            return None
+        try:
+            self._schedule_render_callback()
+        except Exception:
+            msg = f"Failed to schedule render via {self._schedule_render_callback}"
+            logger.exception(msg)
+        else:
+            self._scheduled_render = True
+
+    def use_state(self, function: Callable[[], T]) -> T:
+        """Add state to this hook
+
+        If this hook has not yet rendered, the state is appended to the state tuple.
+        Otherwise, the state is retrieved from the tuple. This allows state to be
+        preserved across renders.
+        """
+        if not self._rendered_atleast_once:
+            # since we're not initialized yet we're just appending state
+            result = function()
+            self._state += (result,)
+        else:
+            # once finalized we iterate over each succesively used piece of state
+            result = self._state[self._current_state_index]
+        self._current_state_index += 1
+        return result
+
+    def add_effect(self, effect_func: EffectFunc) -> None:
+        """Add an effect to this hook
+
+        A task to run the effect is created when the component is done rendering.
+        When the component will be unmounted, the event passed to the effect is
+        triggered and the task is awaited. The effect should eventually halt after
+        the event is triggered.
+        """
+        self._effect_funcs.append(effect_func)
+
+    def set_context_provider(self, provider: ContextProviderType[Any]) -> None:
+        """Set a context provider for this hook
+
+        The context provider will be used to provide state to any child components
+        of this hook's component which request a context provider of the same type.
+        """
+        self._context_providers[provider.type] = provider
+
+    def get_context_provider(
+        self, context: Context[T]
+    ) -> ContextProviderType[T] | None:
+        """Get a context provider for this hook of the given type
+
+        The context provider will have been set by a parent component. If no provider
+        is found, ``None`` is returned.
+        """
+        return self._context_providers.get(context)
+
+    async def affect_component_will_render(self, component: ComponentType) -> None:
+        """The component is about to render"""
+        await self._render_access.acquire()
+        self._scheduled_render = False
+        self.component = component
+        self.set_current()
+
+    async def affect_component_did_render(self) -> None:
+        """The component completed a render"""
+        self.unset_current()
+        self._rendered_atleast_once = True
+        self._current_state_index = 0
+        self._render_access.release()
+        del self.component
+
+    async def affect_layout_did_render(self) -> None:
+        """The layout completed a render"""
+        stop = Event()
+        self._effect_stops.append(stop)
+        self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs)
+        self._effect_funcs.clear()
+
+    async def affect_component_will_unmount(self) -> None:
+        """The component is about to be removed from the layout"""
+        for stop in self._effect_stops:
+            stop.set()
+        self._effect_stops.clear()
+        try:
+            await gather(*self._effect_tasks)
+        except Exception:
+            logger.exception("Error in effect")
+        finally:
+            self._effect_tasks.clear()
+
+    def set_current(self) -> None:
+        """Set this hook as the active hook in this thread
+
+        This method is called by a layout before entering the render method
+        of this hook's associated component.
+        """
+        hook_stack = _HOOK_STATE.get()
+        if hook_stack:
+            parent = hook_stack[-1]
+            self._context_providers.update(parent._context_providers)
+        hook_stack.append(self)
+
+    def unset_current(self) -> None:
+        """Unset this hook as the active hook in this thread"""
+        if _HOOK_STATE.get().pop() is not self:
+            raise RuntimeError("Hook stack is in an invalid state")  # nocov
diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py
index a8334458b..4513dadef 100644
--- a/src/py/reactpy/reactpy/core/hooks.py
+++ b/src/py/reactpy/reactpy/core/hooks.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import asyncio
-from collections.abc import Awaitable, Sequence
+from collections.abc import Coroutine, Sequence
 from logging import getLogger
 from types import FunctionType
 from typing import (
@@ -9,7 +9,6 @@
     Any,
     Callable,
     Generic,
-    NewType,
     Protocol,
     TypeVar,
     cast,
@@ -19,8 +18,8 @@
 from typing_extensions import TypeAlias
 
 from reactpy.config import REACTPY_DEBUG_MODE
-from reactpy.core._thread_local import ThreadLocal
-from reactpy.core.types import ComponentType, Key, State, VdomDict
+from reactpy.core._life_cycle_hook import current_hook
+from reactpy.core.types import Context, Key, State, VdomDict
 from reactpy.utils import Ref
 
 if not TYPE_CHECKING:
@@ -96,7 +95,9 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
 
 _EffectCleanFunc: TypeAlias = "Callable[[], None]"
 _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
-_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]"
+_AsyncEffectFunc: TypeAlias = (
+    "Callable[[], Coroutine[None, None, _EffectCleanFunc | None]]"
+)
 _EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
 
 
@@ -147,25 +148,30 @@ def add_effect(function: _EffectApplyFunc) -> None:
             async_function = cast(_AsyncEffectFunc, function)
 
             def sync_function() -> _EffectCleanFunc | None:
-                future = asyncio.ensure_future(async_function())
+                task = asyncio.create_task(async_function())
 
                 def clean_future() -> None:
-                    if not future.cancel():
-                        clean = future.result()
-                        if clean is not None:
-                            clean()
+                    if not task.cancel():
+                        try:
+                            clean = task.result()
+                        except asyncio.CancelledError:
+                            pass
+                        else:
+                            if clean is not None:
+                                clean()
 
                 return clean_future
 
-        def effect() -> None:
+        async def effect(stop: asyncio.Event) -> None:
             if last_clean_callback.current is not None:
                 last_clean_callback.current()
-
+                last_clean_callback.current = None
             clean = last_clean_callback.current = sync_function()
+            await stop.wait()
             if clean is not None:
-                hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean)
+                clean()
 
-        return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect))
+        return memoize(lambda: hook.add_effect(effect))
 
     if function is not None:
         add_effect(function)
@@ -212,8 +218,8 @@ def context(
         *children: Any,
         value: _Type = default_value,
         key: Key | None = None,
-    ) -> ContextProvider[_Type]:
-        return ContextProvider(
+    ) -> _ContextProvider[_Type]:
+        return _ContextProvider(
             *children,
             value=value,
             key=key,
@@ -225,18 +231,6 @@ def context(
     return context
 
 
-class Context(Protocol[_Type]):
-    """Returns a :class:`ContextProvider` component"""
-
-    def __call__(
-        self,
-        *children: Any,
-        value: _Type = ...,
-        key: Key | None = ...,
-    ) -> ContextProvider[_Type]:
-        ...
-
-
 def use_context(context: Context[_Type]) -> _Type:
     """Get the current value for the given context type.
 
@@ -255,10 +249,10 @@ def use_context(context: Context[_Type]) -> _Type:
             raise TypeError(f"{context} has no 'value' kwarg")  # nocov
         return cast(_Type, context.__kwdefaults__["value"])
 
-    return provider._value
+    return provider.value
 
 
-class ContextProvider(Generic[_Type]):
+class _ContextProvider(Generic[_Type]):
     def __init__(
         self,
         *children: Any,
@@ -269,14 +263,14 @@ def __init__(
         self.children = children
         self.key = key
         self.type = type
-        self._value = value
+        self.value = value
 
     def render(self) -> VdomDict:
         current_hook().set_context_provider(self)
         return {"tagName": "", "children": self.children}
 
     def __repr__(self) -> str:
-        return f"{type(self).__name__}({self.type})"
+        return f"ContextProvider({self.type})"
 
 
 _ActionType = TypeVar("_ActionType")
@@ -495,231 +489,6 @@ def _try_to_infer_closure_values(
         return values
 
 
-def current_hook() -> LifeCycleHook:
-    """Get the current :class:`LifeCycleHook`"""
-    hook_stack = _hook_stack.get()
-    if not hook_stack:
-        msg = "No life cycle hook is active. Are you rendering in a layout?"
-        raise RuntimeError(msg)
-    return hook_stack[-1]
-
-
-_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list)
-
-
-EffectType = NewType("EffectType", str)
-"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved"""
-
-COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER")
-"""An effect that will be triggered each time a component renders"""
-
-LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER")
-"""An effect that will be triggered each time a layout renders"""
-
-COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT")
-"""An effect that will be triggered just before the component is unmounted"""
-
-
-class LifeCycleHook:
-    """Defines the life cycle of a layout component.
-
-    Components can request access to their own life cycle events and state through hooks
-    while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle
-    forward by triggering events and rendering view changes.
-
-    Example:
-
-        If removed from the complexities of a layout, a very simplified full life cycle
-        for a single component with no child components would look a bit like this:
-
-        .. testcode::
-
-            from reactpy.core.hooks import (
-                current_hook,
-                LifeCycleHook,
-                COMPONENT_DID_RENDER_EFFECT,
-            )
-
-
-            # this function will come from a layout implementation
-            schedule_render = lambda: ...
-
-            # --- start life cycle ---
-
-            hook = LifeCycleHook(schedule_render)
-
-            # --- start render cycle ---
-
-            hook.affect_component_will_render(...)
-
-            hook.set_current()
-
-            try:
-                # render the component
-                ...
-
-                # the component may access the current hook
-                assert current_hook() is hook
-
-                # and save state or add effects
-                current_hook().use_state(lambda: ...)
-                current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...)
-            finally:
-                hook.unset_current()
-
-            hook.affect_component_did_render()
-
-            # This should only be called after the full set of changes associated with a
-            # given render have been completed.
-            hook.affect_layout_did_render()
-
-            # Typically an event occurs and a new render is scheduled, thus beginning
-            # the render cycle anew.
-            hook.schedule_render()
-
-
-            # --- end render cycle ---
-
-            hook.affect_component_will_unmount()
-            del hook
-
-            # --- end render cycle ---
-    """
-
-    __slots__ = (
-        "__weakref__",
-        "_context_providers",
-        "_current_state_index",
-        "_event_effects",
-        "_is_rendering",
-        "_rendered_atleast_once",
-        "_schedule_render_callback",
-        "_schedule_render_later",
-        "_state",
-        "component",
-    )
-
-    component: ComponentType
-
-    def __init__(
-        self,
-        schedule_render: Callable[[], None],
-    ) -> None:
-        self._context_providers: dict[Context[Any], ContextProvider[Any]] = {}
-        self._schedule_render_callback = schedule_render
-        self._schedule_render_later = False
-        self._is_rendering = False
-        self._rendered_atleast_once = False
-        self._current_state_index = 0
-        self._state: tuple[Any, ...] = ()
-        self._event_effects: dict[EffectType, list[Callable[[], None]]] = {
-            COMPONENT_DID_RENDER_EFFECT: [],
-            LAYOUT_DID_RENDER_EFFECT: [],
-            COMPONENT_WILL_UNMOUNT_EFFECT: [],
-        }
-
-    def schedule_render(self) -> None:
-        if self._is_rendering:
-            self._schedule_render_later = True
-        else:
-            self._schedule_render()
-
-    def use_state(self, function: Callable[[], _Type]) -> _Type:
-        if not self._rendered_atleast_once:
-            # since we're not initialized yet we're just appending state
-            result = function()
-            self._state += (result,)
-        else:
-            # once finalized we iterate over each succesively used piece of state
-            result = self._state[self._current_state_index]
-        self._current_state_index += 1
-        return result
-
-    def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None:
-        """Trigger a function on the occurrence of the given effect type"""
-        self._event_effects[effect_type].append(function)
-
-    def set_context_provider(self, provider: ContextProvider[Any]) -> None:
-        self._context_providers[provider.type] = provider
-
-    def get_context_provider(
-        self, context: Context[_Type]
-    ) -> ContextProvider[_Type] | None:
-        return self._context_providers.get(context)
-
-    def affect_component_will_render(self, component: ComponentType) -> None:
-        """The component is about to render"""
-        self.component = component
-
-        self._is_rendering = True
-        self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear()
-
-    def affect_component_did_render(self) -> None:
-        """The component completed a render"""
-        del self.component
-
-        component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT]
-        for effect in component_did_render_effects:
-            try:
-                effect()
-            except Exception:
-                logger.exception(f"Component post-render effect {effect} failed")
-        component_did_render_effects.clear()
-
-        self._is_rendering = False
-        self._rendered_atleast_once = True
-        self._current_state_index = 0
-
-    def affect_layout_did_render(self) -> None:
-        """The layout completed a render"""
-        layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT]
-        for effect in layout_did_render_effects:
-            try:
-                effect()
-            except Exception:
-                logger.exception(f"Layout post-render effect {effect} failed")
-        layout_did_render_effects.clear()
-
-        if self._schedule_render_later:
-            self._schedule_render()
-        self._schedule_render_later = False
-
-    def affect_component_will_unmount(self) -> None:
-        """The component is about to be removed from the layout"""
-        will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT]
-        for effect in will_unmount_effects:
-            try:
-                effect()
-            except Exception:
-                logger.exception(f"Pre-unmount effect {effect} failed")
-        will_unmount_effects.clear()
-
-    def set_current(self) -> None:
-        """Set this hook as the active hook in this thread
-
-        This method is called by a layout before entering the render method
-        of this hook's associated component.
-        """
-        hook_stack = _hook_stack.get()
-        if hook_stack:
-            parent = hook_stack[-1]
-            self._context_providers.update(parent._context_providers)
-        hook_stack.append(self)
-
-    def unset_current(self) -> None:
-        """Unset this hook as the active hook in this thread"""
-        if _hook_stack.get().pop() is not self:
-            raise RuntimeError("Hook stack is in an invalid state")  # nocov
-
-    def _schedule_render(self) -> None:
-        try:
-            self._schedule_render_callback()
-        except Exception:
-            logger.exception(
-                f"Failed to schedule render via {self._schedule_render_callback}"
-            )
-
-
 def strictly_equal(x: Any, y: Any) -> bool:
     """Check if two values are identical or, for a limited set or types, equal.
 
diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py
index 3252ba75c..d59ab31eb 100644
--- a/src/py/reactpy/reactpy/core/layout.py
+++ b/src/py/reactpy/reactpy/core/layout.py
@@ -1,10 +1,18 @@
 from __future__ import annotations
 
 import abc
-import asyncio
+from asyncio import (
+    FIRST_COMPLETED,
+    CancelledError,
+    Queue,
+    Task,
+    create_task,
+    get_running_loop,
+    wait,
+)
 from collections import Counter
 from collections.abc import Iterator
-from contextlib import ExitStack
+from contextlib import AsyncExitStack
 from logging import getLogger
 from typing import (
     Any,
@@ -18,8 +26,14 @@
 from uuid import uuid4
 from weakref import ref as weakref
 
-from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE
-from reactpy.core.hooks import LifeCycleHook
+from anyio import Semaphore
+
+from reactpy.config import (
+    REACTPY_ASYNC_RENDERING,
+    REACTPY_CHECK_VDOM_SPEC,
+    REACTPY_DEBUG_MODE,
+)
+from reactpy.core._life_cycle_hook import LifeCycleHook
 from reactpy.core.types import (
     ComponentType,
     EventHandlerDict,
@@ -41,6 +55,8 @@ class Layout:
         "root",
         "_event_handlers",
         "_rendering_queue",
+        "_render_tasks",
+        "_render_tasks_ready",
         "_root_life_cycle_state_id",
         "_model_states_by_life_cycle_state_id",
     )
@@ -58,21 +74,30 @@ def __init__(self, root: ComponentType) -> None:
     async def __aenter__(self) -> Layout:
         # create attributes here to avoid access before entering context manager
         self._event_handlers: EventHandlerDict = {}
+        self._render_tasks: set[Task[LayoutUpdateMessage]] = set()
+        self._render_tasks_ready: Semaphore = Semaphore(0)
 
         self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue()
-        root_model_state = _new_root_model_state(self.root, self._rendering_queue.put)
+        root_model_state = _new_root_model_state(self.root, self._schedule_render_task)
 
         self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id
-        self._rendering_queue.put(root_id)
-
         self._model_states_by_life_cycle_state_id = {root_id: root_model_state}
+        self._schedule_render_task(root_id)
 
         return self
 
     async def __aexit__(self, *exc: Any) -> None:
         root_csid = self._root_life_cycle_state_id
         root_model_state = self._model_states_by_life_cycle_state_id[root_csid]
-        self._unmount_model_states([root_model_state])
+
+        for t in self._render_tasks:
+            t.cancel()
+            try:
+                await t
+            except CancelledError:
+                pass
+
+        await self._unmount_model_states([root_model_state])
 
         # delete attributes here to avoid access after exiting context manager
         del self._event_handlers
@@ -100,6 +125,12 @@ async def deliver(self, event: LayoutEventMessage) -> None:
             )
 
     async def render(self) -> LayoutUpdateMessage:
+        if REACTPY_ASYNC_RENDERING.current:
+            return await self._concurrent_render()
+        else:  # nocov
+            return await self._serial_render()
+
+    async def _serial_render(self) -> LayoutUpdateMessage:  # nocov
         """Await the next available render. This will block until a component is updated"""
         while True:
             model_state_id = await self._rendering_queue.get()
@@ -111,19 +142,27 @@ async def render(self) -> LayoutUpdateMessage:
                     f"{model_state_id!r} - component already unmounted"
                 )
             else:
-                update = self._create_layout_update(model_state)
-                if REACTPY_CHECK_VDOM_SPEC.current:
-                    root_id = self._root_life_cycle_state_id
-                    root_model = self._model_states_by_life_cycle_state_id[root_id]
-                    validate_vdom_json(root_model.model.current)
-                return update
-
-    def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage:
+                return await self._create_layout_update(model_state)
+
+    async def _concurrent_render(self) -> LayoutUpdateMessage:
+        """Await the next available render. This will block until a component is updated"""
+        await self._render_tasks_ready.acquire()
+        done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED)
+        update_task: Task[LayoutUpdateMessage] = done.pop()
+        self._render_tasks.remove(update_task)
+        return update_task.result()
+
+    async def _create_layout_update(
+        self, old_state: _ModelState
+    ) -> LayoutUpdateMessage:
         new_state = _copy_component_model_state(old_state)
         component = new_state.life_cycle_state.component
 
-        with ExitStack() as exit_stack:
-            self._render_component(exit_stack, old_state, new_state, component)
+        async with AsyncExitStack() as exit_stack:
+            await self._render_component(exit_stack, old_state, new_state, component)
+
+        if REACTPY_CHECK_VDOM_SPEC.current:
+            validate_vdom_json(new_state.model.current)
 
         return {
             "type": "layout-update",
@@ -131,9 +170,9 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage:
             "model": new_state.model.current,
         }
 
-    def _render_component(
+    async def _render_component(
         self,
-        exit_stack: ExitStack,
+        exit_stack: AsyncExitStack,
         old_state: _ModelState | None,
         new_state: _ModelState,
         component: ComponentType,
@@ -143,9 +182,8 @@ def _render_component(
 
         self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state
 
-        life_cycle_hook.affect_component_will_render(component)
-        exit_stack.callback(life_cycle_hook.affect_layout_did_render)
-        life_cycle_hook.set_current()
+        await life_cycle_hook.affect_component_will_render(component)
+        exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render)
         try:
             raw_model = component.render()
             # wrap the model in a fragment (i.e. tagName="") to ensure components have
@@ -154,7 +192,7 @@ def _render_component(
             wrapper_model: VdomDict = {"tagName": ""}
             if raw_model is not None:
                 wrapper_model["children"] = [raw_model]
-            self._render_model(exit_stack, old_state, new_state, wrapper_model)
+            await self._render_model(exit_stack, old_state, new_state, wrapper_model)
         except Exception as error:
             logger.exception(f"Failed to render {component}")
             new_state.model.current = {
@@ -166,8 +204,7 @@ def _render_component(
                 ),
             }
         finally:
-            life_cycle_hook.unset_current()
-            life_cycle_hook.affect_component_did_render()
+            await life_cycle_hook.affect_component_did_render()
 
         try:
             parent = new_state.parent
@@ -188,9 +225,9 @@ def _render_component(
                 ],
             }
 
-    def _render_model(
+    async def _render_model(
         self,
-        exit_stack: ExitStack,
+        exit_stack: AsyncExitStack,
         old_state: _ModelState | None,
         new_state: _ModelState,
         raw_model: Any,
@@ -205,7 +242,7 @@ def _render_model(
         if "importSource" in raw_model:
             new_state.model.current["importSource"] = raw_model["importSource"]
         self._render_model_attributes(old_state, new_state, raw_model)
-        self._render_model_children(
+        await self._render_model_children(
             exit_stack, old_state, new_state, raw_model.get("children", [])
         )
 
@@ -272,9 +309,9 @@ def _render_model_event_handlers_without_old_state(
 
         return None
 
-    def _render_model_children(
+    async def _render_model_children(
         self,
-        exit_stack: ExitStack,
+        exit_stack: AsyncExitStack,
         old_state: _ModelState | None,
         new_state: _ModelState,
         raw_children: Any,
@@ -284,12 +321,12 @@ def _render_model_children(
 
         if old_state is None:
             if raw_children:
-                self._render_model_children_without_old_state(
+                await self._render_model_children_without_old_state(
                     exit_stack, new_state, raw_children
                 )
             return None
         elif not raw_children:
-            self._unmount_model_states(list(old_state.children_by_key.values()))
+            await self._unmount_model_states(list(old_state.children_by_key.values()))
             return None
 
         child_type_key_tuples = list(_process_child_type_and_key(raw_children))
@@ -303,7 +340,7 @@ def _render_model_children(
 
         old_keys = set(old_state.children_by_key).difference(new_keys)
         if old_keys:
-            self._unmount_model_states(
+            await self._unmount_model_states(
                 [old_state.children_by_key[key] for key in old_keys]
             )
 
@@ -319,7 +356,7 @@ def _render_model_children(
                         key,
                     )
                 elif old_child_state.is_component_state:
-                    self._unmount_model_states([old_child_state])
+                    await self._unmount_model_states([old_child_state])
                     new_child_state = _make_element_model_state(
                         new_state,
                         index,
@@ -332,7 +369,9 @@ def _render_model_children(
                         new_state,
                         index,
                     )
-                self._render_model(exit_stack, old_child_state, new_child_state, child)
+                await self._render_model(
+                    exit_stack, old_child_state, new_child_state, child
+                )
                 new_state.append_child(new_child_state.model.current)
                 new_state.children_by_key[key] = new_child_state
             elif child_type is _COMPONENT_TYPE:
@@ -344,19 +383,19 @@ def _render_model_children(
                         index,
                         key,
                         child,
-                        self._rendering_queue.put,
+                        self._schedule_render_task,
                     )
                 elif old_child_state.is_component_state and (
                     old_child_state.life_cycle_state.component.type != child.type
                 ):
-                    self._unmount_model_states([old_child_state])
+                    await self._unmount_model_states([old_child_state])
                     old_child_state = None
                     new_child_state = _make_component_model_state(
                         new_state,
                         index,
                         key,
                         child,
-                        self._rendering_queue.put,
+                        self._schedule_render_task,
                     )
                 else:
                     new_child_state = _update_component_model_state(
@@ -364,20 +403,20 @@ def _render_model_children(
                         new_state,
                         index,
                         child,
-                        self._rendering_queue.put,
+                        self._schedule_render_task,
                     )
-                self._render_component(
+                await self._render_component(
                     exit_stack, old_child_state, new_child_state, child
                 )
             else:
                 old_child_state = old_state.children_by_key.get(key)
                 if old_child_state is not None:
-                    self._unmount_model_states([old_child_state])
+                    await self._unmount_model_states([old_child_state])
                 new_state.append_child(child)
 
-    def _render_model_children_without_old_state(
+    async def _render_model_children_without_old_state(
         self,
-        exit_stack: ExitStack,
+        exit_stack: AsyncExitStack,
         new_state: _ModelState,
         raw_children: list[Any],
     ) -> None:
@@ -394,18 +433,18 @@ def _render_model_children_without_old_state(
         for index, (child, child_type, key) in enumerate(child_type_key_tuples):
             if child_type is _DICT_TYPE:
                 child_state = _make_element_model_state(new_state, index, key)
-                self._render_model(exit_stack, None, child_state, child)
+                await self._render_model(exit_stack, None, child_state, child)
                 new_state.append_child(child_state.model.current)
                 new_state.children_by_key[key] = child_state
             elif child_type is _COMPONENT_TYPE:
                 child_state = _make_component_model_state(
-                    new_state, index, key, child, self._rendering_queue.put
+                    new_state, index, key, child, self._schedule_render_task
                 )
-                self._render_component(exit_stack, None, child_state, child)
+                await self._render_component(exit_stack, None, child_state, child)
             else:
                 new_state.append_child(child)
 
-    def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
+    async def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
         to_unmount = old_states[::-1]  # unmount in reversed order of rendering
         while to_unmount:
             model_state = to_unmount.pop()
@@ -416,10 +455,25 @@ def _unmount_model_states(self, old_states: list[_ModelState]) -> None:
             if model_state.is_component_state:
                 life_cycle_state = model_state.life_cycle_state
                 del self._model_states_by_life_cycle_state_id[life_cycle_state.id]
-                life_cycle_state.hook.affect_component_will_unmount()
+                await life_cycle_state.hook.affect_component_will_unmount()
 
             to_unmount.extend(model_state.children_by_key.values())
 
+    def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None:
+        if not REACTPY_ASYNC_RENDERING.current:
+            self._rendering_queue.put(lcs_id)
+            return None
+        try:
+            model_state = self._model_states_by_life_cycle_state_id[lcs_id]
+        except KeyError:
+            logger.debug(
+                "Did not render component with model state ID "
+                f"{lcs_id!r} - component already unmounted"
+            )
+        else:
+            self._render_tasks.add(create_task(self._create_layout_update(model_state)))
+            self._render_tasks_ready.release()
+
     def __repr__(self) -> str:
         return f"{type(self).__name__}({self.root})"
 
@@ -538,6 +592,7 @@ class _ModelState:
     __slots__ = (
         "__weakref__",
         "_parent_ref",
+        "_render_semaphore",
         "children_by_key",
         "index",
         "key",
@@ -649,11 +704,9 @@ class _LifeCycleState(NamedTuple):
 
 
 class _ThreadSafeQueue(Generic[_Type]):
-    __slots__ = "_loop", "_queue", "_pending"
-
     def __init__(self) -> None:
-        self._loop = asyncio.get_running_loop()
-        self._queue: asyncio.Queue[_Type] = asyncio.Queue()
+        self._loop = get_running_loop()
+        self._queue: Queue[_Type] = Queue()
         self._pending: set[_Type] = set()
 
     def put(self, value: _Type) -> None:
@@ -662,10 +715,7 @@ def put(self, value: _Type) -> None:
             self._loop.call_soon_threadsafe(self._queue.put_nowait, value)
 
     async def get(self) -> _Type:
-        while True:
-            value = await self._queue.get()
-            if value in self._pending:
-                break
+        value = await self._queue.get()
         self._pending.remove(value)
         return value
 
diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py
index 194706c6e..e5a81814f 100644
--- a/src/py/reactpy/reactpy/core/types.py
+++ b/src/py/reactpy/reactpy/core/types.py
@@ -233,3 +233,26 @@ class LayoutEventMessage(TypedDict):
     """The ID of the event handler."""
     data: Sequence[Any]
     """A list of event data passed to the event handler."""
+
+
+class Context(Protocol[_Type]):
+    """Returns a :class:`ContextProvider` component"""
+
+    def __call__(
+        self,
+        *children: Any,
+        value: _Type = ...,
+        key: Key | None = ...,
+    ) -> ContextProviderType[_Type]:
+        ...
+
+
+class ContextProviderType(ComponentType, Protocol[_Type]):
+    """A component which provides a context value to its children"""
+
+    type: Context[_Type]
+    """The context type"""
+
+    @property
+    def value(self) -> _Type:
+        "Current context value"
diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py
index 6d126fd2e..c1eb18ba5 100644
--- a/src/py/reactpy/reactpy/testing/common.py
+++ b/src/py/reactpy/reactpy/testing/common.py
@@ -13,8 +13,8 @@
 from typing_extensions import ParamSpec
 
 from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR
+from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook
 from reactpy.core.events import EventHandler, to_event_handler_function
-from reactpy.core.hooks import LifeCycleHook, current_hook
 
 
 def clear_reactpy_web_modules_dir() -> None:
@@ -67,7 +67,7 @@ async def until(
                 break
             elif (time.time() - started_at) > timeout:  # nocov
                 msg = f"Expected {description} after {timeout} seconds - last value was {result!r}"
-                raise TimeoutError(msg)
+                raise asyncio.TimeoutError(msg)
 
     async def until_is(
         self,
diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py
index 4766fe801..1ac04395a 100644
--- a/src/py/reactpy/reactpy/types.py
+++ b/src/py/reactpy/reactpy/types.py
@@ -6,10 +6,10 @@
 
 from reactpy.backend.types import BackendType, Connection, Location
 from reactpy.core.component import Component
-from reactpy.core.hooks import Context
 from reactpy.core.types import (
     ComponentConstructor,
     ComponentType,
+    Context,
     EventHandlerDict,
     EventHandlerFunc,
     EventHandlerMapping,
diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py
index 21b23c12e..743d67f02 100644
--- a/src/py/reactpy/tests/conftest.py
+++ b/src/py/reactpy/tests/conftest.py
@@ -8,14 +8,18 @@
 from _pytest.config.argparsing import Parser
 from playwright.async_api import async_playwright
 
-from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
+from reactpy.config import (
+    REACTPY_ASYNC_RENDERING,
+    REACTPY_TESTING_DEFAULT_TIMEOUT,
+)
 from reactpy.testing import (
     BackendFixture,
     DisplayFixture,
     capture_reactpy_logs,
     clear_reactpy_web_modules_dir,
 )
-from tests.tooling.loop import open_event_loop
+
+REACTPY_ASYNC_RENDERING.current = True
 
 
 def pytest_addoption(parser: Parser) -> None:
@@ -33,13 +37,13 @@ async def display(server, page):
         yield display
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture
 async def server():
     async with BackendFixture() as server:
         yield server
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture
 async def page(browser):
     pg = await browser.new_page()
     pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000)
@@ -49,18 +53,18 @@ async def page(browser):
         await pg.close()
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture
 async def browser(pytestconfig: Config):
     async with async_playwright() as pw:
         yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed))
 
 
 @pytest.fixture(scope="session")
-def event_loop():
+def event_loop_policy():
     if os.name == "nt":  # nocov
-        asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
-    with open_event_loop() as loop:
-        yield loop
+        return asyncio.WindowsProactorEventLoopPolicy()
+    else:
+        return asyncio.DefaultEventLoopPolicy()
 
 
 @pytest.fixture(autouse=True)
diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py
index d697e5d3f..dc8ec1284 100644
--- a/src/py/reactpy/tests/test_backend/test_all.py
+++ b/src/py/reactpy/tests/test_backend/test_all.py
@@ -14,7 +14,6 @@
 @pytest.fixture(
     params=[*list(all_implementations()), default_implementation],
     ids=lambda imp: imp.__name__,
-    scope="module",
 )
 async def display(page, request):
     imp: BackendType = request.param
diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py
index 3c7250e48..a9ff10a89 100644
--- a/src/py/reactpy/tests/test_client.py
+++ b/src/py/reactpy/tests/test_client.py
@@ -30,6 +30,11 @@ def SomeComponent():
             ),
         )
 
+    async def get_count():
+        # need to refetch element because may unmount on reconnect
+        count = await page.wait_for_selector("#count")
+        return await count.get_attribute("data-count")
+
     async with AsyncExitStack() as exit_stack:
         server = await exit_stack.enter_async_context(BackendFixture(port=port))
         display = await exit_stack.enter_async_context(
@@ -38,11 +43,10 @@ def SomeComponent():
 
         await display.show(SomeComponent)
 
-        count = await page.wait_for_selector("#count")
         incr = await page.wait_for_selector("#incr")
 
         for i in range(3):
-            assert (await count.get_attribute("data-count")) == str(i)
+            await poll(get_count).until_equals(str(i))
             await incr.click()
 
     # the server is disconnected but the last view state is still shown
@@ -57,13 +61,7 @@ def SomeComponent():
         # use mount instead of show to avoid a page refresh
         display.backend.mount(SomeComponent)
 
-        async def get_count():
-            # need to refetch element because may unmount on reconnect
-            count = await page.wait_for_selector("#count")
-            return await count.get_attribute("data-count")
-
         for i in range(3):
-            # it may take a moment for the websocket to reconnect so need to poll
             await poll(get_count).until_equals(str(i))
 
             # need to refetch element because may unmount on reconnect
@@ -98,11 +96,15 @@ def ButtonWithChangingColor():
 
     button = await display.page.wait_for_selector("#my-button")
 
-    assert (await _get_style(button))["background-color"] == "red"
+    await poll(_get_style, button).until(
+        lambda style: style["background-color"] == "red"
+    )
 
     for color in ["blue", "red"] * 2:
         await button.click()
-        assert (await _get_style(button))["background-color"] == color
+        await poll(_get_style, button).until(
+            lambda style, c=color: style["background-color"] == c
+        )
 
 
 async def _get_style(element):
diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py
index 453d07c99..fa6acafd1 100644
--- a/src/py/reactpy/tests/test_core/test_hooks.py
+++ b/src/py/reactpy/tests/test_core/test_hooks.py
@@ -5,12 +5,8 @@
 import reactpy
 from reactpy import html
 from reactpy.config import REACTPY_DEBUG_MODE
-from reactpy.core.hooks import (
-    COMPONENT_DID_RENDER_EFFECT,
-    LifeCycleHook,
-    current_hook,
-    strictly_equal,
-)
+from reactpy.core._life_cycle_hook import LifeCycleHook
+from reactpy.core.hooks import strictly_equal, use_effect
 from reactpy.core.layout import Layout
 from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll
 from reactpy.testing.logs import assert_reactpy_did_not_log
@@ -32,10 +28,15 @@ def SimpleComponentWithHook():
 
 
 async def test_simple_stateful_component():
+    index = 0
+
+    def set_index(x):
+        return None
+
     @reactpy.component
     def SimpleStatefulComponent():
+        nonlocal index, set_index
         index, set_index = reactpy.hooks.use_state(0)
-        set_index(index + 1)
         return reactpy.html.div(index)
 
     sse = SimpleStatefulComponent()
@@ -49,6 +50,7 @@ def SimpleStatefulComponent():
                 "children": [{"tagName": "div", "children": ["0"]}],
             },
         )
+        set_index(index + 1)
 
         update_2 = await layout.render()
         assert update_2 == update_message(
@@ -58,6 +60,7 @@ def SimpleStatefulComponent():
                 "children": [{"tagName": "div", "children": ["1"]}],
             },
         )
+        set_index(index + 1)
 
         update_3 = await layout.render()
         assert update_3 == update_message(
@@ -278,18 +281,18 @@ def double_set_state(event):
     first = await display.page.wait_for_selector("#first")
     second = await display.page.wait_for_selector("#second")
 
-    assert (await first.get_attribute("data-value")) == "0"
-    assert (await second.get_attribute("data-value")) == "0"
+    await poll(first.get_attribute, "data-value").until_equals("0")
+    await poll(second.get_attribute, "data-value").until_equals("0")
 
     await button.click()
 
-    assert (await first.get_attribute("data-value")) == "1"
-    assert (await second.get_attribute("data-value")) == "1"
+    await poll(first.get_attribute, "data-value").until_equals("1")
+    await poll(second.get_attribute, "data-value").until_equals("1")
 
     await button.click()
 
-    assert (await first.get_attribute("data-value")) == "2"
-    assert (await second.get_attribute("data-value")) == "2"
+    await poll(first.get_attribute, "data-value").until_equals("2")
+    await poll(second.get_attribute, "data-value").until_equals("2")
 
 
 async def test_use_effect_callback_occurs_after_full_render_is_complete():
@@ -562,7 +565,7 @@ def bad_effect():
 
         return reactpy.html.div()
 
-    with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"):
+    with assert_reactpy_did_log(match_message=r"Error in effect"):
         async with reactpy.Layout(ComponentWithEffect()) as layout:
             await layout.render()  # no error
 
@@ -588,7 +591,7 @@ def bad_cleanup():
         return reactpy.html.div()
 
     with assert_reactpy_did_log(
-        match_message=r"Pre-unmount effect .*? failed",
+        match_message=r"Error in effect",
         error_type=ValueError,
     ):
         async with reactpy.Layout(OuterComponent()) as layout:
@@ -1007,7 +1010,7 @@ def bad_effect():
         return reactpy.html.div()
 
     with assert_reactpy_did_log(
-        match_message=r"post-render effect .*? failed",
+        match_message=r"Error in effect",
         error_type=ValueError,
         match_error="The error message",
     ):
@@ -1030,13 +1033,15 @@ def SetStateDuringRender():
 
     async with Layout(SetStateDuringRender()) as layout:
         await layout.render()
-        assert render_count.current == 1
-        await layout.render()
-        assert render_count.current == 2
 
-        # there should be no more renders to perform
-        with pytest.raises(asyncio.TimeoutError):
-            await asyncio.wait_for(layout.render(), timeout=0.1)
+        # we expect a second render to be triggered in the background
+        await poll(lambda: render_count.current).until_equals(2)
+
+        # give an opportunity for a render to happen if it were to.
+        await asyncio.sleep(0.1)
+
+    # however, we don't expect any more renders
+    assert render_count.current == 2
 
 
 @pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode")
@@ -1240,16 +1245,17 @@ async def test_error_in_component_effect_cleanup_is_gracefully_handled():
     @reactpy.component
     @component_hook.capture
     def ComponentWithEffect():
-        hook = current_hook()
+        @use_effect
+        def effect():
+            def bad_cleanup():
+                raise ValueError("The error message")
 
-        def bad_effect():
-            raise ValueError("The error message")
+            return bad_cleanup
 
-        hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect)
         return reactpy.html.div()
 
     with assert_reactpy_did_log(
-        match_message="Component post-render effect .*? failed",
+        match_message="Error in effect",
         error_type=ValueError,
         match_error="The error message",
     ):
diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py
index 215e89137..9f27727df 100644
--- a/src/py/reactpy/tests/test_core/test_layout.py
+++ b/src/py/reactpy/tests/test_core/test_layout.py
@@ -2,6 +2,7 @@
 import gc
 import random
 import re
+from unittest.mock import patch
 from weakref import finalize
 from weakref import ref as weakref
 
@@ -9,7 +10,7 @@
 
 import reactpy
 from reactpy import html
-from reactpy.config import REACTPY_DEBUG_MODE
+from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE
 from reactpy.core.component import component
 from reactpy.core.hooks import use_effect, use_state
 from reactpy.core.layout import Layout
@@ -20,14 +21,22 @@
     assert_reactpy_did_log,
     capture_reactpy_logs,
 )
+from reactpy.testing.common import poll
 from reactpy.utils import Ref
 from tests.tooling import select
+from tests.tooling.aio import Event
 from tests.tooling.common import event_message, update_message
 from tests.tooling.hooks import use_force_render, use_toggle
 from tests.tooling.layout import layout_runner
 from tests.tooling.select import element_exists, find_element
 
 
+@pytest.fixture(autouse=True, params=[True, False])
+def concurrent_rendering(request):
+    with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param):
+        yield request.param
+
+
 @pytest.fixture(autouse=True)
 def no_logged_errors():
     with capture_reactpy_logs() as logs:
@@ -164,7 +173,7 @@ def make_child_model(state):
 async def test_layout_render_error_has_partial_update_with_error_message():
     @reactpy.component
     def Main():
-        return reactpy.html.div([OkChild(), BadChild(), OkChild()])
+        return reactpy.html.div(OkChild(), BadChild(), OkChild())
 
     @reactpy.component
     def OkChild():
@@ -622,7 +631,7 @@ async def test_hooks_for_keyed_components_get_garbage_collected():
     def Outer():
         items, set_items = reactpy.hooks.use_state([1, 2, 3])
         pop_item.current = lambda: set_items(items[:-1])
-        return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items)
+        return reactpy.html.div([Inner(key=k, finalizer_id=k) for k in items])
 
     @reactpy.component
     def Inner(finalizer_id):
@@ -831,17 +840,19 @@ def some_effect():
     async with reactpy.Layout(Root()) as layout:
         await layout.render()
 
-        assert effects == ["mount x"]
+        await poll(lambda: effects).until_equals(["mount x"])
 
         set_toggle.current()
         await layout.render()
 
-        assert effects == ["mount x", "unmount x", "mount y"]
+        await poll(lambda: effects).until_equals(["mount x", "unmount x", "mount y"])
 
         set_toggle.current()
         await layout.render()
 
-        assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"]
+        await poll(lambda: effects).until_equals(
+            ["mount x", "unmount x", "mount y", "unmount y", "mount x"]
+        )
 
 
 async def test_layout_does_not_copy_element_children_by_key():
@@ -1250,3 +1261,52 @@ def App():
         c, c_info = find_element(tree, select.id_equals("C"))
         assert c_info.path == (0, 1, 0)
         assert c["attributes"]["color"] == "blue"
+
+
+async def test_concurrent_renders(concurrent_rendering):
+    if not concurrent_rendering:
+        raise pytest.skip("Concurrent rendering not enabled")
+
+    child_1_hook = HookCatcher()
+    child_2_hook = HookCatcher()
+    child_1_rendered = Event()
+    child_2_rendered = Event()
+    child_1_render_count = Ref(0)
+    child_2_render_count = Ref(0)
+
+    @component
+    def outer():
+        return html._(child_1(), child_2())
+
+    @component
+    @child_1_hook.capture
+    def child_1():
+        child_1_rendered.set()
+        child_1_render_count.current += 1
+
+    @component
+    @child_2_hook.capture
+    def child_2():
+        child_2_rendered.set()
+        child_2_render_count.current += 1
+
+    async with Layout(outer()) as layout:
+        await layout.render()
+
+        # clear render events and counts
+        child_1_rendered.clear()
+        child_2_rendered.clear()
+        child_1_render_count.current = 0
+        child_2_render_count.current = 0
+
+        # we schedule two renders but expect only one
+        child_1_hook.latest.schedule_render()
+        child_1_hook.latest.schedule_render()
+        child_2_hook.latest.schedule_render()
+        child_2_hook.latest.schedule_render()
+
+        await child_1_rendered.wait()
+        await child_2_rendered.wait()
+
+        assert child_1_render_count.current == 1
+        assert child_2_render_count.current == 1
diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py
index 64be0ec8b..9b22ee866 100644
--- a/src/py/reactpy/tests/test_core/test_serve.py
+++ b/src/py/reactpy/tests/test_core/test_serve.py
@@ -5,10 +5,12 @@
 from jsonpointer import set_pointer
 
 import reactpy
+from reactpy.core.hooks import use_effect
 from reactpy.core.layout import Layout
 from reactpy.core.serve import serve_layout
 from reactpy.core.types import LayoutUpdateMessage
 from reactpy.testing import StaticEventHandler
+from tests.tooling.aio import Event
 from tests.tooling.common import event_message
 
 EVENT_NAME = "on_event"
@@ -96,9 +98,10 @@ async def test_dispatch():
 
 
 async def test_dispatcher_handles_more_than_one_event_at_a_time():
-    block_and_never_set = asyncio.Event()
-    will_block = asyncio.Event()
-    second_event_did_execute = asyncio.Event()
+    did_render = Event()
+    block_and_never_set = Event()
+    will_block = Event()
+    second_event_did_execute = Event()
 
     blocked_handler = StaticEventHandler()
     non_blocked_handler = StaticEventHandler()
@@ -114,6 +117,10 @@ async def block_forever():
         async def handle_event():
             second_event_did_execute.set()
 
+        @use_effect
+        def set_did_render():
+            did_render.set()
+
         return reactpy.html.div(
             reactpy.html.button({"on_click": block_forever}),
             reactpy.html.button({"on_click": handle_event}),
@@ -129,11 +136,12 @@ async def handle_event():
             recv_queue.get,
         )
     )
-
-    await recv_queue.put(event_message(blocked_handler.target))
-    await will_block.wait()
-
-    await recv_queue.put(event_message(non_blocked_handler.target))
-    await second_event_did_execute.wait()
-
-    task.cancel()
+    try:
+        await did_render.wait()
+        await recv_queue.put(event_message(blocked_handler.target))
+        await will_block.wait()
+
+        await recv_queue.put(event_message(non_blocked_handler.target))
+        await second_event_did_execute.wait()
+    finally:
+        task.cancel()
diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py
new file mode 100644
index 000000000..b0f719400
--- /dev/null
+++ b/src/py/reactpy/tests/tooling/aio.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from asyncio import Event as _Event
+from asyncio import wait_for
+
+from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
+
+
+class Event(_Event):
+    """An event with a ``wait_for`` method."""
+
+    async def wait(self, timeout: float | None = None):
+        return await wait_for(
+            super().wait(),
+            timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current,
+        )
diff --git a/src/py/reactpy/tests/tooling/loop.py b/src/py/reactpy/tests/tooling/loop.py
deleted file mode 100644
index f9e100981..000000000
--- a/src/py/reactpy/tests/tooling/loop.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import asyncio
-import threading
-import time
-from asyncio import wait_for
-from collections.abc import Iterator
-from contextlib import contextmanager
-
-from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT
-
-
-@contextmanager
-def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]:
-    """Open a new event loop and cleanly stop it
-
-    Args:
-        as_current: whether to make this loop the current loop in this thread
-    """
-    loop = asyncio.new_event_loop()
-    try:
-        if as_current:
-            asyncio.set_event_loop(loop)
-        loop.set_debug(True)
-        yield loop
-    finally:
-        try:
-            _cancel_all_tasks(loop, as_current)
-            if as_current:
-                loop.run_until_complete(
-                    wait_for(
-                        loop.shutdown_asyncgens(),
-                        REACTPY_TESTING_DEFAULT_TIMEOUT.current,
-                    )
-                )
-                loop.run_until_complete(
-                    wait_for(
-                        loop.shutdown_default_executor(),
-                        REACTPY_TESTING_DEFAULT_TIMEOUT.current,
-                    )
-                )
-        finally:
-            if as_current:
-                asyncio.set_event_loop(None)
-            start = time.time()
-            while loop.is_running():
-                if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current:
-                    msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds"
-                    raise TimeoutError(msg)
-                time.sleep(0.1)
-            loop.close()
-
-
-def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None:
-    to_cancel = asyncio.all_tasks(loop)
-    if not to_cancel:
-        return
-
-    done = threading.Event()
-    count = len(to_cancel)
-
-    def one_task_finished(future):
-        nonlocal count
-        count -= 1
-        if count == 0:
-            done.set()
-
-    for task in to_cancel:
-        loop.call_soon_threadsafe(task.cancel)
-        task.add_done_callback(one_task_finished)
-
-    if is_current:
-        loop.run_until_complete(
-            wait_for(
-                asyncio.gather(*to_cancel, return_exceptions=True),
-                REACTPY_TESTING_DEFAULT_TIMEOUT.current,
-            )
-        )
-    elif not done.wait(timeout=3):  # user was responsible for cancelling all tasks
-        msg = "Could not stop event loop in time"
-        raise TimeoutError(msg)
-
-    for task in to_cancel:
-        if task.cancelled():
-            continue
-        if task.exception() is not None:
-            loop.call_exception_handler(
-                {
-                    "message": "unhandled exception during event loop shutdown",
-                    "exception": task.exception(),
-                    "task": task,
-                }
-            )