Repository: gradio-app/gradio
Pull request: state.change listener with deep hash check
state.change event listeners now do "deep checks", by deep hashing the old value. This makes @gr.render logic much cleaner. See demo/todo_list changes to see how much simpler the code is now because state values can be edited directly to induce a change.

Closes: https://github.com/gradio-app/gradio/issues/8436

Problem statement:
How to modify State(list) to trigger State.change()
It seems that state.change() can only be triggered when variables' id changes, such as int, str, etc. For a list, if only the elements in the list are modified, state.change() cannot be triggered unless a new copy of the list is copied and returned. However, copying the list every time can waste a lot of time and resources. Is there any way to add elements to the list and trigger state.change() without copying the list?

Hints from issue discussion:
We're looking into a solution for this! cc @aliabid94

Commit message: changes

Patch to review:
diff --git a/.changeset/young-lamps-press.md b/.changeset/young-lamps-press.md
new file mode 100644
index 0000000000..710cab5226
--- /dev/null
+++ b/.changeset/young-lamps-press.md
@@ -0,0 +1,5 @@
+---
+"gradio": minor
+---
+
+feat:state.change listener with deep hash check
diff --git a/demo/audio_mixer/run.ipynb b/demo/audio_mixer/run.ipynb
index a19892c361..81fceadd77 100644
--- a/demo/audio_mixer/run.ipynb
+++ b/demo/audio_mixer/run.ipynb
@@ -1 +1 @@
-{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: audio_mixer"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import numpy as np\n", "\n", "with gr.Blocks() as demo:\n", "    track_count = gr.State(1)\n", "    add_track_btn = gr.Button(\"Add Track\")\n", "\n", "    add_track_btn.click(lambda count: count + 1, track_count, track_count)\n", "\n", "    @gr.render(inputs=track_count)\n", "    def render_tracks(count):\n", "        audios = []\n", "        volumes = []\n", "        with gr.Row():\n", "            for i in range(count):\n", "                with gr.Column(variant=\"panel\", min_width=200):\n", "                    gr.Textbox(placeholder=\"Track Name\", key=f\"name-{i}\", show_label=False)\n", "                    track_audio = gr.Audio(label=f\"Track {i}\", key=f\"track-{i}\")\n", "                    track_volume = gr.Slider(0, 100, value=100, label=\"Volume\", key=f\"volume-{i}\")\n", "                    audios.append(track_audio)\n", "                    volumes.append(track_volume)\n", "\n", "            def merge(data):\n", "                sr, output = None, None\n", "                for audio, volume in zip(audios, volumes):\n", "                    sr, audio_val = data[audio]\n", "                    volume_val = data[volume]\n", "                    final_track = audio_val * (volume_val / 100)\n", "                    if output is None:\n", "                        output = final_track\n", "                    else:\n", "                        min_shape = tuple(min(s1, s2) for s1, s2 in zip(output.shape, final_track.shape))\n", "                        trimmed_output = output[:min_shape[0], ...][:, :min_shape[1], ...] if output.ndim > 1 else output[:min_shape[0]]\n", "                        trimmed_final = final_track[:min_shape[0], ...][:, :final_track[1], ...] if final_track.ndim > 1 else final_track[:min_shape[0]]\n", "                        output += trimmed_output + trimmed_final\n", "                return (sr, output)\n", "            \n", "            merge_btn.click(merge, set(audios + volumes), output_audio)\n", "\n", "    merge_btn = gr.Button(\"Merge Tracks\")\n", "    output_audio = gr.Audio(label=\"Output\")\n", "                    \n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
+{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: audio_mixer"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import numpy as np\n", "\n", "with gr.Blocks() as demo:\n", "    track_count = gr.State(1)\n", "    add_track_btn = gr.Button(\"Add Track\")\n", "\n", "    add_track_btn.click(lambda count: count + 1, track_count, track_count)\n", "\n", "    @gr.render(inputs=track_count)\n", "    def render_tracks(count):\n", "        audios = []\n", "        volumes = []\n", "        with gr.Row():\n", "            for i in range(count):\n", "                with gr.Column(variant=\"panel\", min_width=200):\n", "                    gr.Textbox(placeholder=\"Track Name\", key=f\"name-{i}\", show_label=False)\n", "                    track_audio = gr.Audio(label=f\"Track {i}\", key=f\"track-{i}\")\n", "                    track_volume = gr.Slider(0, 100, value=100, label=\"Volume\", key=f\"volume-{i}\")\n", "                    audios.append(track_audio)\n", "                    volumes.append(track_volume)\n", "\n", "            def merge(data):\n", "                sr, output = None, None\n", "                for audio, volume in zip(audios, volumes):\n", "                    sr, audio_val = data[audio]\n", "                    volume_val = data[volume]\n", "                    final_track = audio_val * (volume_val / 100)\n", "                    if output is None:\n", "                        output = final_track\n", "                    else:\n", "                        min_shape = tuple(min(s1, s2) for s1, s2 in zip(output.shape, final_track.shape))\n", "                        trimmed_output = output[:min_shape[0], ...][:, :min_shape[1], ...] if output.ndim > 1 else output[:min_shape[0]]\n", "                        trimmed_final = final_track[:min_shape[0], ...][:, :final_track[1], ...] if final_track.ndim > 1 else final_track[:min_shape[0]]\n", "                        output += trimmed_output + trimmed_final\n", "                return (sr, output)\n", "            \n", "            merge_btn.click(merge, set(audios + volumes), output_audio)\n", "\n", "    merge_btn = gr.Button(\"Merge Tracks\")\n", "    output_audio = gr.Audio(label=\"Output\", interactive=False)\n", "                    \n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
diff --git a/demo/audio_mixer/run.py b/demo/audio_mixer/run.py
index 0dad69ed3e..7b09742e13 100644
--- a/demo/audio_mixer/run.py
+++ b/demo/audio_mixer/run.py
@@ -38,7 +38,7 @@ def merge(data):
             merge_btn.click(merge, set(audios + volumes), output_audio)
 
     merge_btn = gr.Button("Merge Tracks")
-    output_audio = gr.Audio(label="Output")
+    output_audio = gr.Audio(label="Output", interactive=False)
                     
 if __name__ == "__main__":
     demo.launch()
\ No newline at end of file
diff --git a/demo/state_change/run.ipynb b/demo/state_change/run.ipynb
index dedb90e108..77766f285e 100644
--- a/demo/state_change/run.ipynb
+++ b/demo/state_change/run.ipynb
@@ -1 +1 @@
-{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: state_change"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "\n", "with gr.Blocks() as demo:\n", "\n", "    with gr.Row():\n", "        state_a = gr.State(0)\n", "        btn_a = gr.Button(\"Increment A\")\n", "        value_a = gr.Number(label=\"A\")\n", "        btn_a.click(lambda x: x+1, state_a, state_a)\n", "        state_a.change(lambda x: x, state_a, value_a)\n", "    with gr.Row():\n", "        state_b = gr.State(0)\n", "        btn_b = gr.Button(\"Increment B\")\n", "        value_b = gr.Number(label=\"num\")\n", "        btn_b.click(lambda x: x+1, state_b, state_b)\n", "\n", "        @gr.on(inputs=state_b, outputs=value_b)\n", "        def identity(x):\n", "            return x\n", "\n", "    @gr.render(inputs=[state_a, state_b])\n", "    def render(a, b):\n", "        for x in range(a):\n", "            with gr.Row():\n", "                for y in range(b):\n", "                    gr.Button(f\"Button {x}, {y}\")\n", "\n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
+{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: state_change"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "\n", "with gr.Blocks() as demo:\n", "\n", "    with gr.Row():\n", "        state_a = gr.State(0)\n", "        btn_a = gr.Button(\"Increment A\")\n", "        value_a = gr.Number(label=\"A\")\n", "        btn_a.click(lambda x: x+1, state_a, state_a)\n", "        state_a.change(lambda x: x, state_a, value_a)\n", "    with gr.Row():\n", "        state_b = gr.State(0)\n", "        btn_b = gr.Button(\"Increment B\")\n", "        value_b = gr.Number(label=\"num\")\n", "        btn_b.click(lambda x: x+1, state_b, state_b)\n", "\n", "        @gr.on(inputs=state_b, outputs=value_b)\n", "        def identity(x):\n", "            return x\n", "\n", "    @gr.render(inputs=[state_a, state_b])\n", "    def render(a, b):\n", "        for x in range(a):\n", "            with gr.Row():\n", "                for y in range(b):\n", "                    gr.Button(f\"Button {x}, {y}\")\n", "\n", "    list_state = gr.State([])\n", "    dict_state = gr.State(dict())\n", "    nested_list_state = gr.State([])\n", "    set_state = gr.State(set())\n", "\n", "    def transform_list(x):\n", "        return {n: n for n in x}, [x[:] for _ in range(len(x))], set(x)\n", "    \n", "    list_state.change(\n", "        transform_list,\n", "        inputs=list_state,\n", "        outputs=[dict_state, nested_list_state, set_state],\n", "    )\n", "\n", "    all_textbox = gr.Textbox(label=\"Output\")\n", "    change_count = gr.Number(label=\"Changes\")\n", "    gr.on(\n", "        inputs=[change_count, dict_state, nested_list_state, set_state],\n", "        triggers=[dict_state.change, nested_list_state.change, set_state.change],\n", "        fn=lambda x, *args: (x+1, \"\\n\".join(str(arg) for arg in args)),\n", "        outputs=[change_count, all_textbox],\n", "    )\n", "\n", "    count_to_3_btn = gr.Button(\"Count to 3\")\n", "    count_to_3_btn.click(lambda: [1, 2, 3], outputs=list_state)\n", "    zero_all_btn = gr.Button(\"Zero All\")\n", "    zero_all_btn.click(\n", "        lambda x: [0] * len(x), inputs=list_state, outputs=list_state\n", "    )\n", "\n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
diff --git a/demo/state_change/run.py b/demo/state_change/run.py
index fa75ff5523..e7bc0d7b43 100644
--- a/demo/state_change/run.py
+++ b/demo/state_change/run.py
@@ -25,5 +25,35 @@ def render(a, b):
                 for y in range(b):
                     gr.Button(f"Button {x}, {y}")
 
+    list_state = gr.State([])
+    dict_state = gr.State(dict())
+    nested_list_state = gr.State([])
+    set_state = gr.State(set())
+
+    def transform_list(x):
+        return {n: n for n in x}, [x[:] for _ in range(len(x))], set(x)
+    
+    list_state.change(
+        transform_list,
+        inputs=list_state,
+        outputs=[dict_state, nested_list_state, set_state],
+    )
+
+    all_textbox = gr.Textbox(label="Output")
+    change_count = gr.Number(label="Changes")
+    gr.on(
+        inputs=[change_count, dict_state, nested_list_state, set_state],
+        triggers=[dict_state.change, nested_list_state.change, set_state.change],
+        fn=lambda x, *args: (x+1, "\n".join(str(arg) for arg in args)),
+        outputs=[change_count, all_textbox],
+    )
+
+    count_to_3_btn = gr.Button("Count to 3")
+    count_to_3_btn.click(lambda: [1, 2, 3], outputs=list_state)
+    zero_all_btn = gr.Button("Zero All")
+    zero_all_btn.click(
+        lambda x: [0] * len(x), inputs=list_state, outputs=list_state
+    )
+
 if __name__ == "__main__":
     demo.launch()
\ No newline at end of file
diff --git a/demo/todo_list/run.ipynb b/demo/todo_list/run.ipynb
index b6ad0d464e..3e5e80669f 100644
--- a/demo/todo_list/run.ipynb
+++ b/demo/todo_list/run.ipynb
@@ -1 +1 @@
-{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: todo_list"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "\n", "with gr.Blocks() as demo:\n", "    \n", "    tasks = gr.State([])\n", "    new_task = gr.Textbox(label=\"Task Name\", autofocus=True)\n", "\n", "    def add_task(tasks, new_task_name):\n", "        return tasks + [{\"name\": new_task_name, \"complete\": False}], \"\"\n", "\n", "    new_task.submit(add_task, [tasks, new_task], [tasks, new_task])\n", "\n", "    @gr.render(inputs=tasks)\n", "    def render_todos(task_list):\n", "        complete = [task for task in task_list if task[\"complete\"]]\n", "        incomplete = [task for task in task_list if not task[\"complete\"]]\n", "        gr.Markdown(f\"### Incomplete Tasks ({len(incomplete)})\")\n", "        for task in incomplete:\n", "            with gr.Row():\n", "                gr.Textbox(task['name'], show_label=False, container=False)\n", "                done_btn = gr.Button(\"Done\", scale=0)\n", "                def mark_done(task=task):\n", "                    _task_list = task_list[:]\n", "                    _task_list[task_list.index(task)] = {\"name\": task[\"name\"], \"complete\": True}\n", "                    return _task_list\n", "                done_btn.click(mark_done, None, [tasks])\n", "\n", "                delete_btn = gr.Button(\"Delete\", scale=0, variant=\"stop\")\n", "                def delete(task=task):\n", "                    task_index = task_list.index(task)\n", "                    return task_list[:task_index] + task_list[task_index+1:]\n", "                delete_btn.click(delete, None, [tasks])\n", "\n", "        gr.Markdown(f\"### Complete Tasks ({len(complete)})\")\n", "        for task in complete:\n", "            gr.Textbox(task['name'], show_label=False, container=False)\n", "\n", "\n", "\n", "\n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
+{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: todo_list"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "\n", "with gr.Blocks() as demo:\n", "    \n", "    tasks = gr.State([])\n", "    new_task = gr.Textbox(label=\"Task Name\", autofocus=True)\n", "\n", "    def add_task(tasks, new_task_name):\n", "        return tasks + [{\"name\": new_task_name, \"complete\": False}], \"\"\n", "\n", "    new_task.submit(add_task, [tasks, new_task], [tasks, new_task])\n", "\n", "    @gr.render(inputs=tasks)\n", "    def render_todos(task_list):\n", "        complete = [task for task in task_list if task[\"complete\"]]\n", "        incomplete = [task for task in task_list if not task[\"complete\"]]\n", "        gr.Markdown(f\"### Incomplete Tasks ({len(incomplete)})\")\n", "        for task in incomplete:\n", "            with gr.Row():\n", "                gr.Textbox(task['name'], show_label=False, container=False)\n", "                done_btn = gr.Button(\"Done\", scale=0)\n", "                def mark_done(task=task):\n", "                    task[\"complete\"] = True\n", "                    return task_list\n", "                done_btn.click(mark_done, None, [tasks])\n", "\n", "                delete_btn = gr.Button(\"Delete\", scale=0, variant=\"stop\")\n", "                def delete(task=task):\n", "                    task_list.remove(task)\n", "                    return task_list\n", "                delete_btn.click(delete, None, [tasks])\n", "\n", "        gr.Markdown(f\"### Complete Tasks ({len(complete)})\")\n", "        for task in complete:\n", "            gr.Textbox(task['name'], show_label=False, container=False)\n", "\n", "\n", "\n", "\n", "if __name__ == \"__main__\":\n", "    demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
diff --git a/demo/todo_list/run.py b/demo/todo_list/run.py
index a643a636c2..27fdad1dfc 100644
--- a/demo/todo_list/run.py
+++ b/demo/todo_list/run.py
@@ -20,15 +20,14 @@ def render_todos(task_list):
                 gr.Textbox(task['name'], show_label=False, container=False)
                 done_btn = gr.Button("Done", scale=0)
                 def mark_done(task=task):
-                    _task_list = task_list[:]
-                    _task_list[task_list.index(task)] = {"name": task["name"], "complete": True}
-                    return _task_list
+                    task["complete"] = True
+                    return task_list
                 done_btn.click(mark_done, None, [tasks])
 
                 delete_btn = gr.Button("Delete", scale=0, variant="stop")
                 def delete(task=task):
-                    task_index = task_list.index(task)
-                    return task_list[:task_index] + task_list[task_index+1:]
+                    task_list.remove(task)
+                    return task_list
                 delete_btn.click(delete, None, [tasks])
 
         gr.Markdown(f"### Complete Tasks ({len(complete)})")
diff --git a/gradio/blocks.py b/gradio/blocks.py
index 9675e6bb29..d222553904 100644
--- a/gradio/blocks.py
+++ b/gradio/blocks.py
@@ -1718,7 +1718,6 @@ async def postprocess_data(
         self.validate_outputs(block_fn, predictions)  # type: ignore
 
         output = []
-        changed_state_ids = []
         for i, block in enumerate(block_fn.outputs):
             try:
                 if predictions[i] is components._Keywords.FINISHED_ITERATING:
@@ -1732,16 +1731,6 @@ async def postprocess_data(
 
             if block.stateful:
                 if not utils.is_update(predictions[i]):
-                    has_change_event = False
-                    for dep in state.blocks_config.fns.values():
-                        if block._id in [t[0] for t in dep.targets if t[1] == "change"]:
-                            has_change_event = True
-                            break
-                    if has_change_event and (
-                        block._id not in state
-                        or not utils.deep_equal(state[block._id], predictions[i])
-                    ):
-                        changed_state_ids.append(block._id)
                     state[block._id] = predictions[i]
                 output.append(None)
             else:
@@ -1785,7 +1774,7 @@ async def postprocess_data(
                 )
                 output.append(outputs_cached)
 
-        return output, changed_state_ids
+        return output
 
     async def handle_streaming_outputs(
         self,
@@ -1892,6 +1881,8 @@ async def process_api(
         if isinstance(block_fn, int):
             block_fn = self.fns[block_fn]
         batch = block_fn.batch
+        state_ids_to_track, hashed_values = self.get_state_ids_to_track(block_fn, state)
+        changed_state_ids = []
 
         if batch:
             max_batch_size = block_fn.max_batch_size
@@ -1924,11 +1915,10 @@ async def process_api(
                 state,
             )
             preds = result["prediction"]
-            data_and_changed_state_ids = [
+            data = [
                 await self.postprocess_data(block_fn, list(o), state)
                 for o in zip(*preds)
             ]
-            data, changed_state_ids = zip(*data_and_changed_state_ids)
             if root_path is not None:
                 data = processing_utils.add_root_url(data, root_path, None)
             data = list(zip(*data))
@@ -1952,9 +1942,14 @@ async def process_api(
                 in_event_listener,
                 state,
             )
-            data, changed_state_ids = await self.postprocess_data(
-                block_fn, result["prediction"], state
-            )
+            data = await self.postprocess_data(block_fn, result["prediction"], state)
+            if state:
+                changed_state_ids = [
+                    state_id
+                    for hash_value, state_id in zip(hashed_values, state_ids_to_track)
+                    if hash_value != utils.deep_hash(state[state_id])
+                ]
+
             if root_path is not None:
                 data = processing_utils.add_root_url(data, root_path, None)
             is_generating, iterator = result["is_generating"], result["iterator"]
@@ -1995,6 +1990,22 @@ async def process_api(
 
         return output
 
+    def get_state_ids_to_track(
+        self, block_fn: BlockFunction, state: SessionState | None
+    ) -> tuple[list[int], list]:
+        if state is None:
+            return [], []
+        state_ids_to_track = []
+        hashed_values = []
+        for block in block_fn.outputs:
+            if block.stateful and any(
+                (block._id, "change") in fn.targets for fn in self.fns.values()
+            ):
+                value = state[block._id]
+                state_ids_to_track.append(block._id)
+                hashed_values.append(utils.deep_hash(value))
+        return state_ids_to_track, hashed_values
+
     def create_limiter(self):
         self.limiter = (
             None
diff --git a/gradio/helpers.py b/gradio/helpers.py
index d834d2e87f..e450a9e974 100644
--- a/gradio/helpers.py
+++ b/gradio/helpers.py
@@ -326,9 +326,7 @@ async def _postprocess_output(self, output) -> list:
             [output.render() for output in self.outputs]
             demo.load(self.fn, self.inputs, self.outputs)
         demo.unrender()
-        return (await demo.postprocess_data(demo.default_config.fns[0], output, None))[
-            0
-        ]
+        return await demo.postprocess_data(demo.default_config.fns[0], output, None)
 
     def _get_cached_index_if_cached(self, example_index) -> int | None:
         if Path(self.cached_indices_file).exists():
diff --git a/gradio/utils.py b/gradio/utils.py
index 6553bd08ab..6e8f7798bb 100644
--- a/gradio/utils.py
+++ b/gradio/utils.py
@@ -6,6 +6,7 @@
 import asyncio
 import copy
 import functools
+import hashlib
 import importlib
 import importlib.util
 import inspect
@@ -1402,3 +1403,25 @@ def connect_heartbeat(config: dict[str, Any], blocks) -> bool:
                 if any_unload:
                     break
     return any_state or any_unload
+
+
+def deep_hash(obj):
+    """Compute a hash for a deeply nested data structure."""
+    hasher = hashlib.sha256()
+    if isinstance(obj, (int, float, str, bytes)):
+        items = obj
+    elif isinstance(obj, dict):
+        items = tuple(
+            [
+                (k, deep_hash(v))
+                for k, v in sorted(obj.items(), key=lambda x: hash(x[0]))
+            ]
+        )
+    elif isinstance(obj, (list, tuple)):
+        items = tuple(deep_hash(x) for x in obj)
+    elif isinstance(obj, set):
+        items = tuple(deep_hash(x) for x in sorted(obj, key=hash))
+    else:
+        items = str(id(obj)).encode("utf-8")
+    hasher.update(repr(items).encode("utf-8"))
+    return hasher.hexdigest()
diff --git a/guides/04_building-with-blocks/04_dynamic-apps-with-render-decorator.md b/guides/04_building-with-blocks/04_dynamic-apps-with-render-decorator.md
index 2e342c2f01..47013b7eec 100644
--- a/guides/04_building-with-blocks/04_dynamic-apps-with-render-decorator.md
+++ b/guides/04_building-with-blocks/04_dynamic-apps-with-render-decorator.md
@@ -51,7 +51,7 @@ $demo_todo_list
 
 Note that almost the entire app is inside a single `gr.render` that reacts to the tasks `gr.State` variable. This variable is a nested list, which presents some complexity. If you design a `gr.render` to react to a list or dict structure, ensure you do the following:
 
-1. Any event listener that modifies the state variable in a manner that should trigger a re-render should return a new value, not simply modify the state variable directly. A `gr.State` change event listener will not trigger if the state is provided as input and then modified directly. A new variable must be returned that can be compared against. That's why in the `mark_done` method, we create a new list that we edit. Similarly, in `delete`, we create a new list instead of calling `task_list.remove(task)`, and in `add_task`, we don't use `task_list.append`. 
+1. Any event listener that modifies a state variable in a manner that should trigger a re-render must set the state variable as an output. This lets Gradio know to check if the variabl has changed behind the scenes. 
 2. In a `gr.render`, if a variable in a loop is used inside an event listener function, that variable should be "frozen" via setting it to itself as a default argument in the function header. See how we have `task=task` in both `mark_done` and `delete`. This freezes the variable to its "loop-time" value.
 
 Let's take a look at one last example that uses everything we learned. Below is an audio mixer. Provide multiple audio tracks and mix them together.
diff --git a/js/app/test/state_change.spec.ts b/js/app/test/state_change.spec.ts
new file mode 100644
index 0000000000..17abae9c56
--- /dev/null
+++ b/js/app/test/state_change.spec.ts
@@ -0,0 +1,39 @@
+import { test, expect } from "@gradio/tootils";
+
+test("test 2d state-based render", async ({ page }) => {
+	await page.getByRole("button", { name: "Increment A" }).click();
+	await expect(
+		page.locator("button").filter({ hasText: "Button" })
+	).toHaveCount(0);
+	await page.getByRole("button", { name: "Increment B" }).click();
+	await page.getByRole("button", { name: "Increment A" }).click();
+	await expect(
+		page.locator("button").filter({ hasText: "Button" })
+	).toHaveCount(2);
+	await page.getByRole("button", { name: "Increment A" }).click();
+	await page.getByRole("button", { name: "Increment B" }).click();
+	await page.getByRole("button", { name: "Increment A" }).click();
+	await expect(
+		page.locator("button").filter({ hasText: "Button" })
+	).toHaveCount(8);
+});
+
+test("test datastructure-based state changes", async ({ page }) => {
+	await page.getByRole("button", { name: "Count to" }).click();
+	await expect(page.getByLabel("Output")).toHaveValue(
+		`{1: 1, 2: 2, 3: 3}\n[[1, 2, 3], [1, 2, 3], [1, 2, 3]]\n{1, 2, 3}`
+	);
+	await expect(page.getByLabel("Changes")).toHaveValue("1");
+	await page.getByRole("button", { name: "Count to" }).click();
+	await page.getByRole("button", { name: "Count to" }).click();
+	await page.getByRole("button", { name: "Count to" }).click();
+	await expect(page.getByLabel("Output")).toHaveValue(
+		`{1: 1, 2: 2, 3: 3}\n[[1, 2, 3], [1, 2, 3], [1, 2, 3]]\n{1, 2, 3}`
+	);
+	await expect(page.getByLabel("Changes")).toHaveValue("1");
+	await page.getByRole("button", { name: "Zero All" }).click();
+	await expect(page.getByLabel("Output")).toHaveValue(
+		`{0: 0}\n[[0, 0, 0], [0, 0, 0], [0, 0, 0]]\n{0}`
+	);
+	await expect(page.getByLabel("Changes")).toHaveValue("2");
+});
diff --git a/js/app/test/todo_list.spec.ts b/js/app/test/todo_list.spec.ts
new file mode 100644
index 0000000000..a3d0a487b2
--- /dev/null
+++ b/js/app/test/todo_list.spec.ts
@@ -0,0 +1,50 @@
+import { test, expect } from "@gradio/tootils";
+
+test("clicking through tabs shows correct content", async ({ page }) => {
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (0)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (0)");
+
+	const input_text = page.getByLabel("Task Name");
+
+	await input_text.fill("eat");
+	await input_text.press("Enter");
+
+	await expect(page.locator("body")).not.toContainText("Incomplete Tasks (0)");
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (1)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (0)");
+	await expect(page.locator("textarea").nth(1)).toHaveValue("eat");
+
+	await input_text.fill("pray");
+	await input_text.press("Enter");
+
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (2)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (0)");
+	await expect(page.locator("textarea").nth(2)).toHaveValue("pray");
+
+	await input_text.fill("love");
+	await input_text.press("Enter");
+
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (3)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (0)");
+	await expect(page.locator("textarea").nth(1)).toHaveValue("eat");
+	await expect(page.locator("textarea").nth(2)).toHaveValue("pray");
+	await expect(page.locator("textarea").nth(3)).toHaveValue("love");
+
+	const done_btn_for_eat = page
+		.locator("button")
+		.filter({ hasText: "Done" })
+		.first();
+	await done_btn_for_eat.click();
+
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (2)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (1)");
+
+	const delete_btn_for_love = page
+		.locator("button")
+		.filter({ hasText: "Delete" })
+		.last();
+	await delete_btn_for_love.click();
+
+	await expect(page.locator("body")).toContainText("Incomplete Tasks (1)");
+	await expect(page.locator("body")).toContainText("Complete Tasks (1)");
+});
diff --git a/test/test_blocks.py b/test/test_blocks.py
index 271583df74..a4a3221387 100644
--- a/test/test_blocks.py
+++ b/test/test_blocks.py
@@ -525,7 +525,7 @@ async def test_blocks_do_not_filter_none_values_from_updates(self, io_components
                 outputs=io_components,
             )
 
-        output, _ = await demo.postprocess_data(
+        output = await demo.postprocess_data(
             demo.fns[0], [gr.update(value=None) for _ in io_components], state=None
         )
 
@@ -550,7 +550,7 @@ async def test_blocks_does_not_replace_keyword_literal(self):
                 outputs=text,
             )
 
-        output, _ = await demo.postprocess_data(
+        output = await demo.postprocess_data(
             demo.fns[0], gr.update(value="NO_VALUE"), state=None
         )
         assert output[0]["value"] == "NO_VALUE"
@@ -566,7 +566,7 @@ def change_visibility(value):
             checkbox = gr.Checkbox(value=True, label="Show image")
             checkbox.change(change_visibility, inputs=checkbox, outputs=im_list)
 
-        output, _ = await demo.postprocess_data(
+        output = await demo.postprocess_data(
             demo.fns[0], [gr.update(visible=False)] * 2, state=None
         )
         assert output == [
@@ -586,12 +586,12 @@ def update_values(val):
 
             update.click(update_values, inputs=[num], outputs=[num2])
 
-        output, _ = await demo.postprocess_data(
+        output = await demo.postprocess_data(
             demo.fns[0], {num2: gr.Number(value=42)}, state=None
         )
         assert output[0]["value"] == 42
 
-        output, _ = await demo.postprocess_data(demo.fns[0], {num2: 23}, state=None)
+        output = await demo.postprocess_data(demo.fns[0], {num2: 23}, state=None)
         assert output[0] == 23
 
     @pytest.mark.asyncio
@@ -1695,7 +1695,7 @@ def clear_func():
             fn=clear_func, outputs=[chatbot, chatbot2, chatbot3], api_name="clear"
         )
 
-        output, _ = await demo.postprocess_data(
+        output = await demo.postprocess_data(
             demo.fns[0], [gr.Chatbot(value=[])] * 3, None
         )
         assert output == [{"value": [], "__type__": "update"}] * 3


Code review comments:
diff_hunk: @@ -51,7 +51,7 @@ $demo_todo_list
 
 Note that almost the entire app is inside a single `gr.render` that reacts to the tasks `gr.State` variable. This variable is a nested list, which presents some complexity. If you design a `gr.render` to react to a list or dict structure, ensure you do the following:
 
-1. Any event listener that modifies the state variable in a manner that should trigger a re-render should return a new value, not simply modify the state variable directly. A `gr.State` change event listener will not trigger if the state is provided as input and then modified directly. A new variable must be returned that can be compared against. That's why in the `mark_done` method, we create a new list that we edit. Similarly, in `delete`, we create a new list instead of calling `task_list.remove(task)`, and in `add_task`, we don't use `task_list.append`. 
+1. Any event listener that modifies a state variable in a manner that should trigger a re-render must set the state variable as an output. This lets Gradio know to check if the variabl has changed behind the scenes. 
path: guides/04_building-with-blocks/04_dynamic-apps-with-render-decorator.md
text: @user1:
```suggestion
1. Any event listener that modifies a state variable in a manner that should trigger a re-render must set the state variable as an output. This lets Gradio know to check if the variable has changed behind the scenes. 
```