Repository: reactive-python/reactpy-django (language: python)

Issue:
`use_channel_layer` hook
### Old Behavior

Currently there is no way of sharing component states across multiple clients.

### Proposed Actions

Add in the ability for arbitrary data to be synchronized cross-client.

We will use Django Channels layers to communicate a state across multiple clients. In order to use this feature, the user will need to first configure a [Django Channels Layer](https://channels.readthedocs.io/en/stable/topics/channel_layers.html) with a backend such as `redis`.

The user interface might end up looking like this:

```python
@component
def example():
    sender = use_channel_layers(receiver, channel_name="example", group_name="example")

    return ...

async def receiver(event: dict):
    ...
```

### Code of Conduct

- [X] I agree to follow the [Code of Conduct](https://github.com/idom-team/django-idom/blob/main/CODE_OF_CONDUCT.md).


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23612904..a52bd859 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,9 @@ Using the following categories, list your changes in this order:
 
 ## [Unreleased]
 
--   Nothing (yet)!
+### Added
+
+-   Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook.
 
 ## [3.7.0] - 2024-01-30
 
diff --git a/docs/examples/python/use-channel-layer-group.py b/docs/examples/python/use-channel-layer-group.py
new file mode 100644
index 00000000..2fde6bab
--- /dev/null
+++ b/docs/examples/python/use-channel-layer-group.py
@@ -0,0 +1,40 @@
+from reactpy import component, hooks, html
+from reactpy_django.hooks import use_channel_layer
+
+
+@component
+def my_sender_component():
+    sender = use_channel_layer("my-channel-name", group=True)
+
+    async def submit_event(event):
+        if event["key"] == "Enter":
+            await sender({"text": event["target"]["value"]})
+
+    return html.div(
+        "Message Sender: ",
+        html.input({"type": "text", "onKeyDown": submit_event}),
+    )
+
+
+@component
+def my_receiver_component_1():
+    message, set_message = hooks.use_state("")
+
+    async def receive_event(message):
+        set_message(message["text"])
+
+    use_channel_layer("my-channel-name", receiver=receive_event, group=True)
+
+    return html.div(f"Message Receiver 1: {message}")
+
+
+@component
+def my_receiver_component_2():
+    message, set_message = hooks.use_state("")
+
+    async def receive_event(message):
+        set_message(message["text"])
+
+    use_channel_layer("my-channel-name", receiver=receive_event, group=True)
+
+    return html.div(f"Message Receiver 2: {message}")
diff --git a/docs/examples/python/use-channel-layer-signal-receiver.py b/docs/examples/python/use-channel-layer-signal-receiver.py
new file mode 100644
index 00000000..57a92321
--- /dev/null
+++ b/docs/examples/python/use-channel-layer-signal-receiver.py
@@ -0,0 +1,14 @@
+from reactpy import component, hooks, html
+from reactpy_django.hooks import use_channel_layer
+
+
+@component
+def my_receiver_component():
+    message, set_message = hooks.use_state("")
+
+    async def receive_event(message):
+        set_message(message["text"])
+
+    use_channel_layer("my-channel-name", receiver=receive_event)
+
+    return html.div(f"Message Receiver: {message}")
diff --git a/docs/examples/python/use-channel-layer-signal-sender.py b/docs/examples/python/use-channel-layer-signal-sender.py
new file mode 100644
index 00000000..4e12fb12
--- /dev/null
+++ b/docs/examples/python/use-channel-layer-signal-sender.py
@@ -0,0 +1,20 @@
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+from django.db.models import Model
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
+
+
+class ExampleModel(Model):
+    ...
+
+
+@receiver(pre_save, sender=ExampleModel)
+def my_sender_signal(sender, instance, **kwargs):
+    layer = get_channel_layer()
+
+    # Example of sending a message to a channel
+    async_to_sync(layer.send)("my-channel-name", {"text": "Hello World!"})
+
+    # Example of sending a message to a group channel
+    async_to_sync(layer.group_send)("my-channel-name", {"text": "Hello World!"})
diff --git a/docs/examples/python/use-channel-layer.py b/docs/examples/python/use-channel-layer.py
new file mode 100644
index 00000000..36e3a40b
--- /dev/null
+++ b/docs/examples/python/use-channel-layer.py
@@ -0,0 +1,28 @@
+from reactpy import component, hooks, html
+from reactpy_django.hooks import use_channel_layer
+
+
+@component
+def my_sender_component():
+    sender = use_channel_layer("my-channel-name")
+
+    async def submit_event(event):
+        if event["key"] == "Enter":
+            await sender({"text": event["target"]["value"]})
+
+    return html.div(
+        "Message Sender: ",
+        html.input({"type": "text", "onKeyDown": submit_event}),
+    )
+
+
+@component
+def my_receiver_component():
+    message, set_message = hooks.use_state("")
+
+    async def receive_event(message):
+        set_message(message["text"])
+
+    use_channel_layer("my-channel-name", receiver=receive_event)
+
+    return html.div(f"Message Receiver: {message}")
diff --git a/docs/src/about/changelog.md b/docs/src/about/changelog.md
index 1ecf88e2..e08ee1f9 100644
--- a/docs/src/about/changelog.md
+++ b/docs/src/about/changelog.md
@@ -3,6 +3,12 @@ hide:
     - toc
 ---
 
+<!--
+
+If you see this page, you probably meant to visit the other CHANGELOG.md (all caps).
+
+-->
+
 <p class="intro" markdown>
 
 {% include-markdown "../../../CHANGELOG.md" start="<!--attr-start-->" end="<!--attr-end-->" %}
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index 58f6eeec..dee45011 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -38,3 +38,4 @@ misconfiguration
 misconfigurations
 backhaul
 sublicense
+broadcasted
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
index 197b59be..96fe0616 100644
--- a/docs/src/reference/hooks.md
+++ b/docs/src/reference/hooks.md
@@ -275,7 +275,11 @@ Mutation functions can be sync or async.
 
 ### Use User Data
 
-Store or retrieve data (`#!python dict`) specific to the connection's `#!python User`. This data is stored in the `#!python REACTPY_DATABASE`.
+Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`.
+
+This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs.
+
+User data saved with this hook is stored within the `#!python REACTPY_DATABASE`.
 
 === "components.py"
 
@@ -312,6 +316,103 @@ Store or retrieve data (`#!python dict`) specific to the connection's `#!python
 
 ---
 
+## Communication Hooks
+
+---
+
+### Use Channel Layer
+
+Subscribe to a [Django Channels layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to send/receive messages.
+
+Layers are a multiprocessing-safe communication system that allows you to send/receive messages between different parts of your application.
+
+This is often used to create chat systems, synchronize data between components, or signal re-renders from outside your components.
+
+=== "components.py"
+
+    ```python
+    {% include "../../examples/python/use-channel-layer.py" %}
+    ```
+
+??? example "See Interface"
+
+    <font size="4">**Parameters**</font>
+
+    | Name | Type | Description | Default |
+    | --- | --- | --- | --- |
+    | `#!python name` | `#!python str` | The name of the channel to subscribe to. | N/A |
+    | `#!python receiver` | `#!python AsyncMessageReceiver | None` | An async function that receives a `#!python message: dict` from the channel layer. If more than one receiver waits on the same channel, a random one will get the result (unless `#!python group=True` is defined). | `#!python None` |
+    | `#!python group` | `#!python bool` | If `#!python True`, a "group channel" will be used. Messages sent within a group are broadcasted to all receivers on that channel. | `#!python False` |
+    | `#!python layer` | `#!python str` | The channel layer to use. These layers must be defined in `#!python settings.py:CHANNEL_LAYERS`. | `#!python 'default'` |
+
+    <font size="4">**Returns**</font>
+
+    | Type | Description |
+    | --- | --- |
+    | `#!python AsyncMessageSender` | An async callable that can send a `#!python message: dict` |
+
+??? warning "Extra Django configuration required"
+
+    In order to use this hook, you will need to configure Django to enable channel layers.
+
+    The [Django Channels documentation](https://channels.readthedocs.io/en/latest/topics/channel_layers.html#configuration) has information on what steps you need to take.
+
+    In summary, you will need to:
+
+    1. Run the following command to install `channels-redis` in your Python environment.
+
+        ```bash linenums="0"
+        pip install channels-redis
+        ```
+
+    2. Configure your `settings.py` to use `RedisChannelLayer` as your layer backend.
+
+        ```python linenums="0"
+        CHANNEL_LAYERS = {
+            "default": {
+                "BACKEND": "channels_redis.core.RedisChannelLayer",
+                "CONFIG": {
+                    "hosts": [("127.0.0.1", 6379)],
+                },
+            },
+        }
+        ```
+
+??? question "How do I broadcast a message to multiple components?"
+
+    By default, if more than one receiver waits on the same channel, a random one will get the result.
+
+    However, by defining `#!python group=True` you can configure a "group channel", which will broadcast messages to all receivers.
+
+    In the example below, all messages sent by the `#!python sender` component will be received by all `#!python receiver` components that exist (across every active client browser).
+
+    === "components.py"
+
+        ```python
+        {% include "../../examples/python/use-channel-layer-group.py" %}
+        ```
+
+??? question "How do I signal a re-render from something that isn't a component?"
+
+    There are occasions where you may want to signal a re-render from something that isn't a component, such as a Django model signal.
+
+    In these cases, you can use the `#!python use_channel_layer` hook to receive a signal within your component, and then use the `#!python get_channel_layer().send(...)` to send the signal.
+
+    In the example below, the sender will send a signal every time `#!python ExampleModel` is saved. Then, when the receiver component gets this signal, it explicitly calls `#!python set_message(...)` to trigger a re-render.
+
+    === "components.py"
+
+        ```python
+        {% include "../../examples/python/use-channel-layer-signal-receiver.py" %}
+        ```
+    === "signals.py"
+
+        ```python
+        {% include "../../examples/python/use-channel-layer-signal-sender.py" %}
+        ```
+
+---
+
 ## Connection Hooks
 
 ---
diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md
index 2b56bb4d..af5353e8 100644
--- a/docs/src/reference/router.md
+++ b/docs/src/reference/router.md
@@ -2,7 +2,7 @@
 
 <p class="intro" markdown>
 
-A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions.
+A Single Page Application URL router, which is a variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that uses Django conventions.
 
 </p>
 
diff --git a/requirements/check-types.txt b/requirements/check-types.txt
index c176075a..c962b716 100644
--- a/requirements/check-types.txt
+++ b/requirements/check-types.txt
@@ -1,2 +1,3 @@
 mypy
 django-stubs[compatible-mypy]
+channels-redis
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 0ebf2ea8..96da2d10 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -13,10 +13,13 @@
     cast,
     overload,
 )
+from uuid import uuid4
 
 import orjson as pickle
+from channels import DEFAULT_CHANNEL_LAYER
 from channels.db import database_sync_to_async
-from reactpy import use_callback, use_effect, use_ref, use_state
+from channels.layers import InMemoryChannelLayer, get_channel_layer
+from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
 from reactpy import use_connection as _use_connection
 from reactpy import use_location as _use_location
 from reactpy import use_scope as _use_scope
@@ -24,6 +27,8 @@
 
 from reactpy_django.exceptions import UserNotFoundError
 from reactpy_django.types import (
+    AsyncMessageReceiver,
+    AsyncMessageSender,
     ConnectionType,
     FuncParams,
     Inferred,
@@ -36,6 +41,7 @@
 from reactpy_django.utils import generate_obj_name, get_user_pk
 
 if TYPE_CHECKING:
+    from channels_redis.core import RedisChannelLayer
     from django.contrib.auth.models import AbstractUser
 
 
@@ -361,6 +367,65 @@ async def _set_user_data(data: dict):
     return UserData(query, mutation)
 
 
+def use_channel_layer(
+    name: str,
+    receiver: AsyncMessageReceiver | None = None,
+    group: bool = False,
+    layer: str = DEFAULT_CHANNEL_LAYER,
+) -> AsyncMessageSender:
+    """
+    Subscribe to a Django Channels layer to send/receive messages.
+
+    Args:
+        name: The name of the channel to subscribe to.
+        receiver: An async function that receives a `message: dict` from the channel layer. \
+            If more than one receiver waits on the same channel, a random one \
+            will get the result (unless `group=True` is defined).
+        group: If `True`, a "group channel" will be used. Messages sent within a \
+            group are broadcasted to all receivers on that channel.
+        layer: The channel layer to use. These layers must be defined in \
+            `settings.py:CHANNEL_LAYERS`.
+    """
+    channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer)
+    channel_name = use_memo(lambda: str(uuid4() if group else name))
+    group_name = name if group else ""
+
+    if not channel_layer:
+        raise ValueError(
+            f"Channel layer '{layer}' is not available. Are you sure you"
+            " configured settings.py:CHANNEL_LAYERS properly?"
+        )
+
+    # Add/remove a group's channel during component mount/dismount respectively.
+    @use_effect(dependencies=[])
+    async def group_manager():
+        if group:
+            await channel_layer.group_add(group_name, channel_name)
+
+            return lambda: asyncio.run(
+                channel_layer.group_discard(group_name, channel_name)
+            )
+
+    # Listen for messages on the channel using the provided `receiver` function.
+    @use_effect
+    async def message_receiver():
+        if not receiver or not channel_name:
+            return
+
+        while True:
+            message = await channel_layer.receive(channel_name)
+            await receiver(message)
+
+    # User interface for sending messages to the channel
+    async def message_sender(message: dict):
+        if group:
+            await channel_layer.group_send(group_name, message)
+        else:
+            await channel_layer.send(channel_name, message)
+
+    return message_sender
+
+
 def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs):
     return options, query, args, kwargs
 
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index e049cd26..8efda72f 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -109,3 +109,13 @@ class ComponentParams:
 class UserData(NamedTuple):
     query: Query[dict | None]
     mutation: Mutation[dict]
+
+
+class AsyncMessageReceiver(Protocol):
+    async def __call__(self, message: dict) -> None:
+        ...
+
+
+class AsyncMessageSender(Protocol):
+    async def __call__(self, message: dict) -> None:
+        ...


Test patch:
diff --git a/tests/test_app/channel_layers/__init__.py b/tests/test_app/channel_layers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py
new file mode 100644
index 00000000..d9174b47
--- /dev/null
+++ b/tests/test_app/channel_layers/components.py
@@ -0,0 +1,64 @@
+from reactpy import component, hooks, html
+from reactpy_django.hooks import use_channel_layer
+
+
+@component
+def receiver():
+    state, set_state = hooks.use_state("None")
+
+    async def receiver(message):
+        set_state(message["text"])
+
+    use_channel_layer("channel-messenger", receiver=receiver)
+
+    return html.div(
+        {"id": "receiver", "data-message": state},
+        f"Message Receiver: {state}",
+    )
+
+
+@component
+def sender():
+    sender = use_channel_layer("channel-messenger")
+
+    async def submit_event(event):
+        if event["key"] == "Enter":
+            await sender({"text": event["target"]["value"]})
+
+    return html.div(
+        "Message Sender: ",
+        html.input(
+            {"type": "text", "id": "sender", "onKeyDown": submit_event},
+        ),
+    )
+
+
+@component
+def group_receiver(id: int):
+    state, set_state = hooks.use_state("None")
+
+    async def receiver(message):
+        set_state(message["text"])
+
+    use_channel_layer("group-messenger", receiver=receiver, group=True)
+
+    return html.div(
+        {"id": f"group-receiver-{id}", "data-message": state},
+        f"Group Message Receiver #{id}: {state}",
+    )
+
+
+@component
+def group_sender():
+    sender = use_channel_layer("group-messenger", group=True)
+
+    async def submit_event(event):
+        if event["key"] == "Enter":
+            await sender({"text": event["target"]["value"]})
+
+    return html.div(
+        "Group Message Sender: ",
+        html.input(
+            {"type": "text", "id": "group-sender", "onKeyDown": submit_event},
+        ),
+    )
diff --git a/tests/test_app/channel_layers/urls.py b/tests/test_app/channel_layers/urls.py
new file mode 100644
index 00000000..e9d84b4b
--- /dev/null
+++ b/tests/test_app/channel_layers/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from test_app.channel_layers.views import channel_layers
+
+urlpatterns = [
+    path("channel-layers/", channel_layers),
+]
diff --git a/tests/test_app/channel_layers/views.py b/tests/test_app/channel_layers/views.py
new file mode 100644
index 00000000..786b307f
--- /dev/null
+++ b/tests/test_app/channel_layers/views.py
@@ -0,0 +1,5 @@
+from django.shortcuts import render
+
+
+def channel_layers(request, path=None):
+    return render(request, "channel_layers.html", {})
diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py
index bb1fcf5a..65e37415 100644
--- a/tests/test_app/settings_multi_db.py
+++ b/tests/test_app/settings_multi_db.py
@@ -155,5 +155,8 @@
     },
 }
 
+# Django Channels Settings
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
+
 # ReactPy-Django Settings
 REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv
diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py
index e98a63a7..2550c8d1 100644
--- a/tests/test_app/settings_single_db.py
+++ b/tests/test_app/settings_single_db.py
@@ -136,5 +136,8 @@
     },
 }
 
+# Django Channels Settings
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
+
 # ReactPy-Django Settings
 REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv
diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html
new file mode 100644
index 00000000..af3db04b
--- /dev/null
+++ b/tests/test_app/templates/channel_layers.html
@@ -0,0 +1,30 @@
+{% load static %} {% load reactpy %}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
+    <title>ReactPy</title>
+</head>
+
+<body>
+    <h1>ReactPy Channel Layers Test Page</h1>
+    <hr>
+    {% component "test_app.channel_layers.components.receiver" %}
+    <hr>
+    {% component "test_app.channel_layers.components.sender" %}
+    <hr>
+    {% component "test_app.channel_layers.components.group_receiver" id=1 %}
+    <hr>
+    {% component "test_app.channel_layers.components.group_receiver" id=2 %}
+    <hr>
+    {% component "test_app.channel_layers.components.group_receiver" id=3 %}
+    <hr>
+    {% component "test_app.channel_layers.components.group_sender" %}
+    <hr>
+</body>
+
+</html>
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py
index 15d5b553..241e5659 100644
--- a/tests/test_app/tests/test_components.py
+++ b/tests/test_app/tests/test_components.py
@@ -640,3 +640,32 @@ def test_offline_components(self):
 
         finally:
             new_page.close()
+
+    def test_channel_layer_components(self):
+        new_page = self.browser.new_page()
+        try:
+            new_page.goto(f"{self.live_server_url}/channel-layers/")
+            sender = new_page.wait_for_selector("#sender")
+            sender.type("test", delay=CLICK_DELAY)
+            sender.press("Enter", delay=CLICK_DELAY)
+            receiver = new_page.wait_for_selector("#receiver[data-message='test']")
+            self.assertIsNotNone(receiver)
+
+            sender = new_page.wait_for_selector("#group-sender")
+            sender.type("1234", delay=CLICK_DELAY)
+            sender.press("Enter", delay=CLICK_DELAY)
+            receiver_1 = new_page.wait_for_selector(
+                "#group-receiver-1[data-message='1234']"
+            )
+            receiver_2 = new_page.wait_for_selector(
+                "#group-receiver-2[data-message='1234']"
+            )
+            receiver_3 = new_page.wait_for_selector(
+                "#group-receiver-3[data-message='1234']"
+            )
+            self.assertIsNotNone(receiver_1)
+            self.assertIsNotNone(receiver_2)
+            self.assertIsNotNone(receiver_3)
+
+        finally:
+            new_page.close()
diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py
index d15a2817..05acb163 100644
--- a/tests/test_app/urls.py
+++ b/tests/test_app/urls.py
@@ -31,6 +31,7 @@
     path("", include("test_app.performance.urls")),
     path("", include("test_app.router.urls")),
     path("", include("test_app.offline.urls")),
+    path("", include("test_app.channel_layers.urls")),
     path("reactpy/", include("reactpy_django.http.urls")),
     path("admin/", admin.site.urls),
 ]