Repository: deephaven/deephaven-plugins (language: python)

Issue:
Components for HTML elements



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 97101ec9b..c46ea4386 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,12 @@
 node_modules
 .vscode/
 tsconfig.tsbuildinfo
+
+# Ignore user IntelliJ settings
+.idea/
+
+# Ignore users local python venvs
+.venv/
+
+# Ignore any local tox virtualenvs
+.tox/
\ No newline at end of file
diff --git a/README.md b/README.md
index 73b1479ea..2b340e5c9 100644
--- a/README.md
+++ b/README.md
@@ -138,4 +138,4 @@ Using the path to your local deephaven-js-plugins repo where the manifest.json i
 START_OPTS="-Ddeephaven.jsPlugins.resourceBase=<deephaven-plugins-path>deephaven-js-plugins/plugins" ./gradlew server-jetty-app:run
 ```
 
-The deephaven IDE can then be opened at http://localhost:10000/ide/, with your plugins ready to use.
+The Deephaven IDE can then be opened at http://localhost:10000/ide/, with your plugins ready to use.
diff --git a/package-lock.json b/package-lock.json
index febbe3dfa..a5470d787 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3198,6 +3198,10 @@
       "resolved": "plugins/table-example/src/js",
       "link": true
     },
+    "node_modules/@deephaven/js-plugin-ui": {
+      "resolved": "plugins/ui/src/js",
+      "link": true
+    },
     "node_modules/@deephaven/jsapi-bootstrap": {
       "version": "0.40.1",
       "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.40.1.tgz",
@@ -14325,9 +14329,9 @@
       }
     },
     "node_modules/intl-messageformat": {
-      "version": "10.5.2",
-      "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.2.tgz",
-      "integrity": "sha512-X4rlUNbgCc8/RdMhmvUEEZ38yNDn5S4r0u8n8yQH2OOdhsR46SmOuQsCKG35nRXmL5u2nxPsNN6qNhHoMm6FMQ==",
+      "version": "10.5.3",
+      "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.3.tgz",
+      "integrity": "sha512-TzKn1uhJBMyuKTO4zUX47SU+d66fu1W9tVzIiZrQ6hBqQQeYscBMIzKL/qEXnFbJrH9uU5VV3+T5fWib4SIcKA==",
       "dependencies": {
         "@formatjs/ecma402-abstract": "1.17.2",
         "@formatjs/fast-memoize": "2.2.0",
@@ -15062,6 +15066,11 @@
         "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
       }
     },
+    "node_modules/json-rpc-2.0": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.6.0.tgz",
+      "integrity": "sha512-+pKxaoIqnA5VjXmZiAI1+CkFG7mHLg+dhtliOe/mp1P5Gdn8P5kE/Xxp2CUBwnGL7pfw6gC8zWTWekhSnKzHFA=="
+    },
     "node_modules/json-schema-traverse": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -19123,7 +19132,6 @@
       "version": "17.0.2",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
       "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
-      "peer": true,
       "dependencies": {
         "loose-envify": "^1.1.0",
         "object-assign": "^4.1.1",
@@ -20185,7 +20193,6 @@
       "version": "0.20.2",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
       "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
-      "peer": true,
       "dependencies": {
         "loose-envify": "^1.1.0",
         "object-assign": "^4.1.1"
@@ -22574,6 +22581,465 @@
       "engines": {
         "node": ">=4.2.0"
       }
+    },
+    "plugins/ui/src/js": {
+      "name": "@deephaven/js-plugin-ui",
+      "version": "0.0.1",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@adobe/react-spectrum": "^3.29.0",
+        "@deephaven/chart": "^0.49.0",
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/dashboard": "^0.49.0",
+        "@deephaven/dashboard-core-plugins": "^0.49.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/iris-grid": "^0.49.0",
+        "@deephaven/jsapi-bootstrap": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "json-rpc-2.0": "^1.6.0",
+        "shortid": "^2.2.16"
+      },
+      "devDependencies": {
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@types/react": "^17.0.2",
+        "@vitejs/plugin-react-swc": "^3.0.0",
+        "react": "^17.0.2",
+        "react-dom": "^17.0.2",
+        "typescript": "^4.5.4",
+        "vite": "~4.1.4"
+      },
+      "peerDependencies": {
+        "react": "^17.0.2",
+        "react-dom": "^17.0.2"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/chart": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/chart/-/chart-0.49.0.tgz",
+      "integrity": "sha512-fBs2SIYjubHJwln+hx8cdGdGmulaEhzwqbeeUMvI39YGOz2jKe5ler/3afLKhO4CaoVtvUpWWZmHZbYHE/x2sg==",
+      "dependencies": {
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/jsapi-utils": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "deep-equal": "^2.0.5",
+        "lodash.debounce": "^4.0.8",
+        "lodash.set": "^4.3.2",
+        "memoize-one": "^5.1.1",
+        "memoizee": "^0.4.15",
+        "plotly.js": "^2.18.2",
+        "prop-types": "^15.7.2",
+        "react-plotly.js": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/components": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.49.0.tgz",
+      "integrity": "sha512-+wbHM9GZIDQ8Wpo6vFqMZUzx6qBbEpOITpX/AuvVJFGrSSJV4o6rGsN723UZ+nkojp8OSLWaqFnwpn6O2ugvpg==",
+      "dependencies": {
+        "@adobe/react-spectrum": "^3.29.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@fortawesome/fontawesome-svg-core": "^6.2.1",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "@react-spectrum/theme-default": "^3.5.1",
+        "bootstrap": "4.6.2",
+        "classnames": "^2.3.1",
+        "event-target-shim": "^6.0.2",
+        "lodash.clamp": "^4.0.3",
+        "lodash.debounce": "^4.0.8",
+        "lodash.flatten": "^4.4.0",
+        "memoizee": "^0.4.15",
+        "popper.js": "^1.16.1",
+        "prop-types": "^15.7.2",
+        "react-beautiful-dnd": "^13.1.0",
+        "react-transition-group": "^4.4.2",
+        "react-virtualized-auto-sizer": "1.0.6",
+        "react-window": "^1.8.6",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "react": "^17.x",
+        "react-dom": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/console": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/console/-/console-0.49.0.tgz",
+      "integrity": "sha512-kBDgbG5+Q6VBVDoxjxF169fF7RsaQhEPp378DwReX75qyhlU7g0QMpJIh3oq4fBJa9AUHt5aZjWMv+GeBMRQpA==",
+      "dependencies": {
+        "@deephaven/chart": "^0.49.0",
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/jsapi-bootstrap": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/storage": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "classnames": "^2.3.1",
+        "linkifyjs": "^4.1.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.throttle": "^4.1.1",
+        "memoize-one": "^5.1.1",
+        "memoizee": "^0.4.15",
+        "monaco-editor": "^0.41.0",
+        "papaparse": "5.3.2",
+        "popper.js": "^1.16.1",
+        "prop-types": "^15.7.2",
+        "shell-quote": "^1.7.2",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x",
+        "react-dom": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/dashboard": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-0.49.0.tgz",
+      "integrity": "sha512-DcbpGhvSqC1j1ChiVP+og6y9aZRVaMKvk0svsBzVq87UAB4MSkSsfnrBvSgHeb27Xx3WDek39vgiMbDvdfOFYQ==",
+      "dependencies": {
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/golden-layout": "^0.49.0",
+        "@deephaven/jsapi-bootstrap": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/redux": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "deep-equal": "^2.0.5",
+        "lodash.ismatch": "^4.1.1",
+        "lodash.throttle": "^4.1.1",
+        "prop-types": "^15.7.2",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0",
+        "react-dom": "^17.0.0",
+        "react-redux": "^7.2.4"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/dashboard-core-plugins": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard-core-plugins/-/dashboard-core-plugins-0.49.0.tgz",
+      "integrity": "sha512-HBGHndV4myz2/yidcpTykRpuUZtJ2aWilSSo0LYBUD0z4fEWwNi19xdlyh2hjA04EKCnp83o1GLnA5uhy/0uug==",
+      "dependencies": {
+        "@deephaven/chart": "^0.49.0",
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/console": "^0.49.0",
+        "@deephaven/dashboard": "^0.49.0",
+        "@deephaven/file-explorer": "^0.49.0",
+        "@deephaven/filters": "^0.49.0",
+        "@deephaven/golden-layout": "^0.49.0",
+        "@deephaven/grid": "^0.49.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/iris-grid": "^0.49.0",
+        "@deephaven/jsapi-bootstrap": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/jsapi-utils": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/redux": "^0.49.0",
+        "@deephaven/storage": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "classnames": "^2.3.1",
+        "deep-equal": "^2.0.5",
+        "lodash.clamp": "^4.0.3",
+        "lodash.debounce": "^4.0.8",
+        "lodash.throttle": "^4.1.1",
+        "memoize-one": "^5.1.1",
+        "memoizee": "^0.4.15",
+        "prop-types": "^15.7.2",
+        "react-markdown": "^6.0.2",
+        "react-transition-group": "^4.4.2",
+        "redux": "^4.2.0",
+        "redux-thunk": "^2.4.1",
+        "remark-gfm": "1.0.0",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0",
+        "react-dom": "^17.0.0",
+        "react-redux": "^7.2.4"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/file-explorer": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/file-explorer/-/file-explorer-0.49.0.tgz",
+      "integrity": "sha512-eJc3SRzZgUdNL7foijCoTsHMSPKwiTXRGj6aidwEKrrqxHtZ2mCnSSAsmENW5q8WLAbghWXK5Lo/nZe0ZBGhtw==",
+      "dependencies": {
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/storage": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@fortawesome/fontawesome-svg-core": "^6.2.1",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "classnames": "^2.3.1",
+        "lodash.throttle": "^4.1.1",
+        "prop-types": "^15.7.2"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/filters": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/filters/-/filters-0.49.0.tgz",
+      "integrity": "sha512-jlvHAMk1ugj3Up+yY4Y4DkDHPVarqz2pJUp8fDwEpzXe9fn6XRjxYct9qU4tGQrQtznBMnJ6O1II8uBTlDkKGw==",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/golden-layout": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.49.0.tgz",
+      "integrity": "sha512-sCuFcuORMl1uRwkX9XTZLxCqfO3ck19I1i4xCrD6GjbJOl6hIzKyErvK/dsfOyhaqoDQxVHI+RzAzIF6096O4A==",
+      "dependencies": {
+        "@deephaven/components": "^0.49.0",
+        "jquery": "^3.6.0"
+      },
+      "peerDependencies": {
+        "react": "^17.x",
+        "react-dom": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/grid": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/grid/-/grid-0.49.0.tgz",
+      "integrity": "sha512-D6S5JPfHyf0qGP5Kj0Zhw5J/FUosPtUae6jCFyB5hKTjp5DnjcVvskxzWxRRjscSMDW7RqamXTQD2SroIMEP+A==",
+      "dependencies": {
+        "@deephaven/utils": "^0.49.0",
+        "classnames": "^2.3.1",
+        "color-convert": "^2.0.1",
+        "event-target-shim": "^6.0.2",
+        "linkifyjs": "^4.1.0",
+        "lodash.clamp": "^4.0.3",
+        "memoize-one": "^5.1.1",
+        "memoizee": "^0.4.15",
+        "prop-types": "^15.7.2"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/icons": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/icons/-/icons-0.49.0.tgz",
+      "integrity": "sha512-gXb203frGc1OrIgxKSDZ56QnjMxy9AWZFEWZflF25NfxGJZoJh59XgM4dcd/phM6cinyqaLRgqt2CXarbV/mDg==",
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "^6.1.1"
+      },
+      "peerDependencies": {
+        "@fortawesome/fontawesome-svg-core": "^6.2.1",
+        "@fortawesome/react-fontawesome": "^0.2.0"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/iris-grid": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/iris-grid/-/iris-grid-0.49.0.tgz",
+      "integrity": "sha512-IB2pzYgwRYsOr+kpHSOi9BaGorOTD0Z4Mb2cSADEbucOuyKipvCqzs/h+rhTjkK7N+sNn8c+Zrx/ovFoxilS6g==",
+      "dependencies": {
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/console": "^0.49.0",
+        "@deephaven/filters": "^0.49.0",
+        "@deephaven/grid": "^0.49.0",
+        "@deephaven/icons": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/jsapi-utils": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0",
+        "@deephaven/storage": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "@dnd-kit/core": "^6.0.5",
+        "@dnd-kit/sortable": "^7.0.0",
+        "@dnd-kit/utilities": "^3.2.0",
+        "@fortawesome/react-fontawesome": "^0.2.0",
+        "classnames": "^2.3.1",
+        "deep-equal": "^2.0.5",
+        "lodash.clamp": "^4.0.3",
+        "lodash.debounce": "^4.0.8",
+        "lodash.throttle": "^4.1.1",
+        "memoize-one": "^5.1.1",
+        "memoizee": "^0.4.15",
+        "monaco-editor": "^0.41.0",
+        "prop-types": "^15.7.2",
+        "react-beautiful-dnd": "^13.1.0",
+        "react-transition-group": "^4.4.2",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "react": "^17.x",
+        "react-dom": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/jsapi-bootstrap": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.49.0.tgz",
+      "integrity": "sha512-n819XSXG6m0l0l4ZSWpAfEcnLH8Om3wubMr/BEFRzExU5pWnL/VvtUTQgzz505m8PBFg5lboALDmTtA12Vt9bw==",
+      "dependencies": {
+        "@deephaven/components": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/react-hooks": "^0.49.0"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/jsapi-types": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.49.0.tgz",
+      "integrity": "sha512-Hq8qzG+Fu/sMChphPTQKPGM1Eehb3aRk6GHSqJNFE7CzNYjgr/+5SFelAi+hfyw8oGEHJlsqbZnWFiTJObGYMg==",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/jsapi-utils": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.49.0.tgz",
+      "integrity": "sha512-JgEeM5QlgwTSKxGuL6Go9GElgdHL0Vp9cVEXmUa6n/JgrNfmmApaw57B7gYWp/PGkPpY6vtpl4w5BdEsHu+P0A==",
+      "dependencies": {
+        "@deephaven/filters": "^0.49.0",
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "lodash.clamp": "^4.0.3",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/log": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.49.0.tgz",
+      "integrity": "sha512-nsWUoCB32qUqCku9QCXptdDcHlAEfvfDDyzweIh+JCHEkIKd9o0OwDaUVQcO8/uhby1TzXYiTjbqCexa6ztyVQ==",
+      "dependencies": {
+        "event-target-shim": "^6.0.2"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/react-hooks": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.49.0.tgz",
+      "integrity": "sha512-7ReehK7D3+tAasdKu7OGyrchlMX02s0RD573X+PQFnKvVhPwGRMXzX9dkvcapTYtoZZQblSDOyBU+nXsNKm3ew==",
+      "dependencies": {
+        "@adobe/react-spectrum": "^3.29.0",
+        "@deephaven/log": "^0.49.0",
+        "@deephaven/utils": "^0.49.0",
+        "lodash.debounce": "^4.0.8",
+        "shortid": "^2.2.16"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/redux": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-0.49.0.tgz",
+      "integrity": "sha512-uAZjz0PqIG0bv4i/nZrqePZUI7cCDQl99GBz53rvDXAqEidcEdJDDlzfhAsyF7czAUKpcnasUXiexZgbW9mXxw==",
+      "dependencies": {
+        "@deephaven/jsapi-types": "^0.49.0",
+        "@deephaven/jsapi-utils": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "deep-equal": "^2.0.5",
+        "redux-thunk": "2.4.1"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "redux": "^4.2.0"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/storage": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/storage/-/storage-0.49.0.tgz",
+      "integrity": "sha512-CWIRmnTR3BxKL3A16ko5COeXVwf3S3l+hsF2Jo+kaFjHaDWEcpnJgjQpxBMEBPRyf2yh2t47ItY5FqJlk2gK1w==",
+      "dependencies": {
+        "@deephaven/filters": "^0.49.0",
+        "@deephaven/log": "^0.49.0",
+        "lodash.throttle": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "peerDependencies": {
+        "react": "^17.x"
+      }
+    },
+    "plugins/ui/src/js/node_modules/@deephaven/utils": {
+      "version": "0.49.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.49.0.tgz",
+      "integrity": "sha512-mz03TMj4ZZDP86g8a0FAYJhKBjYLMTSXRxn/mO//VYPtf3ZIDDuYkwj0eTRWw9pUOUgL5U1pJbu/0EywtDHRVA==",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "plugins/ui/src/js/node_modules/redux-thunk": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
+      "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==",
+      "peerDependencies": {
+        "redux": "^4"
+      }
+    },
+    "plugins/ui/src/js/node_modules/typescript": {
+      "version": "4.9.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+      "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=4.2.0"
+      }
     }
   }
 }
diff --git a/plugins/auth-keycloak/src/js/src/AuthPluginKeycloak.tsx b/plugins/auth-keycloak/src/js/src/AuthPluginKeycloak.tsx
index e5ec9c327..e1b4bf6a0 100644
--- a/plugins/auth-keycloak/src/js/src/AuthPluginKeycloak.tsx
+++ b/plugins/auth-keycloak/src/js/src/AuthPluginKeycloak.tsx
@@ -6,8 +6,7 @@ import {
   AuthPluginProps,
 } from '@deephaven/auth-plugins';
 import { useBroadcastLoginListener } from '@deephaven/jsapi-components';
-import { LoginOptions } from '@deephaven/jsapi-types';
-import { Log } from '@deephaven/log';
+import Log from '@deephaven/log';
 
 const log = Log.module('@deephaven/js-plugin-auth-keycloak.AuthPluginKeycloak');
 
diff --git a/plugins/manifest.json b/plugins/manifest.json
index 3b6f710bb..9f2b7a927 100644
--- a/plugins/manifest.json
+++ b/plugins/manifest.json
@@ -2,6 +2,7 @@
   "plugins": [
     { "name": "matplotlib", "version": "0.1.0", "main": "src/js/dist/index.js" },
     { "name": "plotly-express", "version": "0.1.0", "main": "src/js/dist/bundle/index.js" },
-    { "name": "auth-keycloak", "version": "0.1.0", "main": "src/js/dist/index.js" }
+    { "name": "auth-keycloak", "version": "0.1.0", "main": "src/js/dist/index.js" },
+    { "name": "ui", "version": "0.1.0", "main": "src/js/dist/index.js" }
   ]
 }
diff --git a/plugins/ui/.gitignore b/plugins/ui/.gitignore
new file mode 100644
index 000000000..3356dc1ce
--- /dev/null
+++ b/plugins/ui/.gitignore
@@ -0,0 +1,8 @@
+build/
+dist/
+.venv/
+/venv
+*.egg-info/
+.idea
+.DS_store
+__pycache__/
\ No newline at end of file
diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md
new file mode 100644
index 000000000..39effc0d5
--- /dev/null
+++ b/plugins/ui/DESIGN.md
@@ -0,0 +1,1342 @@
+# deephaven.ui Plugin (alpha)
+
+Prototype of the deephaven.ui plugin, mocking out some ideas of how to code up programmatic layouts and callbacks. This is currently very much a prototype and should be used for discussion and evaluation purposes only. Name `deephaven.ui` is not set in stone.
+
+## Development Installation/Setup
+
+1. Until a fix for a bug found with exporting custom objects, you'll need to build/run deephaven-core from @niloc132's branch: https://github.com/niloc132/deephaven-core/tree/4338-live-pyobject
+2. Build/Install the `deephaven-plugin-ui` Python plugin in your deephaven-core set up: https://github.com/mofojed/deephaven-plugin-ui
+3. Follow the instructions in the [README.md at the root](../../README.md) of this repository to build/install the JS plugins (including this one).
+
+## Other Solutions/Examples
+
+### Parameterized Query
+
+```groovy
+import io.deephaven.query.parameterized.ParameterizedQuery
+import io.deephaven.query.parameterized.Parameter
+
+myQuery = ParameterizedQuery.create()
+  .with(Parameter.ofLong("low").inRange(0, 20).withDefault(5))
+  .with(Parameter.ofLong("high").inRange(0, 20).withDefault(15))
+  .willDo({scope ->
+    def low = scope.getLong("low")
+    def high = scope.getLong("high")
+    def tableResult = db.t("LearnDeephaven", "StockTrades")
+    .where("Date=`2017-08-25`", "Size<=$high", "Size>=$low")
+    plotResult = plot("Stuff", tableResult, "Timestamp", "Last").show()
+    scope.setResult("tableResult", tableResult)
+    scope.setResult("plotResult", plotResult)
+  }).build()
+```
+
+##### Pros
+
+- Already works
+- Scope is defined, and re-runs the whole scope when any param changes
+- Easy to understand
+
+##### Cons
+
+- Lots of boilerplate
+- Syntax easy to get incorrect
+- Lots of strings
+- No python
+- No specifying different contexts (shared PPQ among sessions/users for example)
+- No composability - cannot re-use PPQs within PPQs, or define a "component" that gets used
+
+### Callbacks with decorators (plotly, Shiny for python)
+
+```python
+from dash import Dash, html, dcc, Input, Output
+
+app = Dash(__name__, external_stylesheets=external_stylesheets)
+app.layout = html.Div(
+    [
+        dcc.RangeSlider(0, 20, 1, value=[5, 15], id="my-range-slider"),
+        html.Div(id="output-container-range-slider"),
+    ]
+)
+
+
+@app.callback(
+    Output("output-container-range-slider", "children"),
+    [Input("my-range-slider", "value")],
+)
+def update_output(value):
+    return 'You have selected "{}"'.format(value)
+
+
+if __name__ == "__main__":
+    app.run_server()
+```
+
+Other examples: https://shiny.posit.co/py/docs/overview.html
+
+##### Pros
+
+- Decorators are nice "magic"
+
+##### Cons
+
+- Lots of strings need to match, easy to make a mistake
+- Difficult to visualize
+- Not sure how to iterate
+- Need to have an object named `app`, so not really "composable"
+
+### Streamlit (re-runs entire script on any change)
+
+```python
+import streamlit as st
+
+x = st.slider("x")
+st.write(x, "squared is", x * x)
+
+
+@st.cache  # tells streamlit to memoize this function though
+def expensive_computation(a, b):
+    time.sleep(2)  # This makes the function take 2s to run
+    return a * b
+
+
+a = 2
+b = 21
+res = expensive_computation(a, b)
+st.write("Result:", res)
+```
+
+##### Pros
+
+- Can use the values easily anywhere in your script
+- Entire script re-runs with any change, easy to understand, easy to iterate
+
+##### Cons
+
+- Re-running everything can be costly, need to be conscious with caching/memoization
+- Does not achieve composability
+
+## Proposed Syntaxes
+
+### Interactive Query
+
+Early prototype: https://github.com/mofojed/deephaven-plugin-interactive
+UI: https://github.com/mofojed/deephaven-js-plugins/tree/interactive
+
+#### Basic Example
+
+Creates a table that simply updates with the value of the slider.
+
+```python
+from deephaven.plugin.interactive import make_iq, dh
+from deephaven import empty_table
+
+
+def my_func(x, a):
+    print("x is now " + str(x))
+    t = empty_table(1).update_view([f"x={x}"])
+    return {"t": t}
+
+
+my_query = make_iq(my_func, x=dh.slider(22, 2, 111))
+```
+
+#### Plotting Example
+
+Create two plots showing a sine function and cosine function with the values set from the slider.
+
+```python
+from deephaven.plugin.interactive import make_iq, dh
+from deephaven import empty_table
+from deephaven.plot.figure import Figure
+
+
+def sin_func(amplitude, frequency, phase):
+    # Note: Should use QST to create filters instead of f-strings?
+    t = empty_table(1000).update_view(
+        ["x=i", f"y={amplitude}*Math.sin(x*{frequency}+{phase})"]
+    )
+    f = Figure().plot_xy(series_name="Series", t=t, x="x", y="y").show()
+    return {"t": t, "f": f}
+
+
+def cos_func(amplitude, frequency, phase):
+    t = empty_table(1000).update_view(
+        ["x=i", f"y={amplitude}*Math.cos(x*{frequency}+{phase})"]
+    )
+    f = Figure().plot_xy(series_name="Series", t=t, x="x", y="y").show()
+    return {"t": t, "f": f}
+
+
+inputs = {"amplitude": dh.slider(1), "frequency": dh.slider(1), "phase": dh.slider(1)}
+
+iqs = make_iq(sin_func, **inputs)
+iqc = make_iq(cos_func, **inputs)
+```
+
+##### Pros
+
+- No magic strings (though does have dictionary keys for kwargs)
+- Scope is defined, and re-runs the whole scope when any param changes
+- Easy to understand
+- Should be "easy" to implement once bidirection plugins are completed
+
+##### Cons
+
+- Not clear how to "chain" inputs (e.g. slider based on a table based on another input control, reacting to a click within a table)... unless nesting functions is allowed
+
+### React-like syntax
+
+Use "React hooks" like inspired syntax to write blocks that "re-render" when state changes. **Note**: These examples are just mockups for illustrating the proposed syntax. They may not actually compile.
+
+#### Components (for composability)
+
+Using a "React-like" syntax, it is possible to define "components" which can be re-used and compose other components. For example, we may want to make a "filterable table" component, that just provides a text input field above a table that you can use to filter a specific column in the table.
+
+![Text filter Table](./assets/filter_table.png)
+
+Read about [React](https://react.dev/learn) and [React Hooks](https://react.dev/reference/react) if you are unfamiliar with them for a primer on the design principles followed. Here is an example of a proposed syntax for that:
+
+```python
+import deephaven.ui as ui
+
+# @ui.component decorator marks a function as a "component" function
+# By adding this decorator, wraps the function such that "hooks" can be used within the function (effectively similar to `React.createElement`). Hooks are functions following the convention `use_*`, can only be used within a `@ui.component` context
+@ui.component
+def text_filter_table(source: Table, column: str):
+    # The value of the text filter is entirely separate from the text input field definition
+    value, set_value = ui.use_state("")
+
+    # TODO: Should be using QST/filters here instead, e.g. https://github.com/deephaven/deephaven-core/issues/3784
+    t = source.where(f"{column}=`{value}`")
+
+    # Return a column that has the text input, then the table below it
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            t,
+        ]
+    )
+```
+
+The above component, could then be re-used, to have two tables side-by-side:
+
+![Double filter table](./assets/double_filter_table.png)
+
+```python
+# Just using one source table, and allowing it to be filtered using two different filter inputs
+@ui.component
+def double_filter_table(source: Table, column: str):
+    return ui.flex(
+        [text_filter_table(source, column), text_filter_table(source, column)],
+        direction="row",
+    )
+```
+
+#### Re-using Components
+
+You can re-use a component, but with different parameters. For example, we may want to have a component that shows an input for `Sym` and the resulting table, and re-use that to show different exchanges:
+
+```python
+@ui.component
+def stock_table(exchange: str):
+    sym, set_sym = use_state("AAPL")
+    table = use_memo(
+        lambda: db.live_table("LearnDeephaven", "StockTrades").where(
+            [f"Exchange=`{exchange}`", f"Sym=`{sym}`"]
+        ),
+        [exchange, sym],
+    )
+    return [ui.text_input(value=sym, on_value_change=set_sym), table]
+
+
+nasdaq_table = stock_table("NASDAQ")
+nyse_table = stock_table("NYSE")
+```
+
+#### Memoization/Caching
+
+React has a hook [useMemo](https://react.dev/reference/react/useMemo) which is used to cache operations if no dependencies have changed. Streamlit has [Caching](https://docs.streamlit.io/library/advanced-features/caching#basic-usage) as well using `@st.cache_data` and `@st.cache_resource` decorators. We will definitely need some sort of caching, we will need to determine the paradigm. Consider first the example without any caching:
+
+```python
+import deephaven.ui as ui
+from deephaven.parquet import read
+
+
+@ui.component
+def my_caching_component(parquet_path="/data/stocks.parquet"):
+    value, set_value = ui.use_state("")
+
+    # This parquet `read` operation fires _every_ time the component is re-rendered, which happens _every_ time the `value` is changed. This is unnecessary, since we only want to re-run the `.where` part and keep the `source` the same.
+    source = read(parquet_path)
+    t = source.where(f"sym=`{value}`")
+
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            t,
+        ]
+    )
+```
+
+Now using a `use_memo` hook, similar to React. This re-enforces the `use_*` hook type behaviour.
+
+```python
+import deephaven.ui as ui
+from deephaven.parquet import read
+
+
+@ui.component
+def text_filter_table(source: Table, column: str):
+    # The value of the text filter is entirely separate from the text input field definition
+    value, set_value = ui.use_state("")
+
+    # TODO: Should be using QST/filters here instead, e.g. https://github.com/deephaven/deephaven-core/issues/3784
+    t = source.where(f"{column}=`{value}`")
+
+    # Return a column that has the text input, then the table below it
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            t,
+        ]
+    )
+
+
+@ui.component
+def my_caching_component(parquet_path="/data/stocks.parquet"):
+    value, set_value = ui.use_state("")
+
+    # The `read` function will only be called whenever `parquet_path` is changed
+    source = use_memo(lambda: read(parquet_path), [parquet_path])
+    t = source.where(f"sym=`{value}`")
+
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            t,
+        ]
+    )
+```
+
+Trying to define it as a decorator gets kind of messy within a functional component. You'd probably want to define at a top level, which is kind of weird:
+
+```python
+import deephaven.ui as ui
+from deephaven.parquet import read
+
+# Decorator wraps function and will only re-run the function if it hasn't run before or if it doesn't already have the result from a previous execution with the same parameters
+@ui.memo
+def parquet_table(path: str):
+    return read(path)
+
+
+@ui.component
+def my_caching_component(parquet_path="/data/stocks.parquet"):
+    value, set_value = ui.use_state("")
+
+    # Memoization is handled by the `parquet_table` method itself
+    source = parquet_table(parquet_path)
+    t = source.where(f"sym=`{value}`")
+
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            t,
+        ]
+    )
+```
+
+#### “One Click” plots with Input Filters
+
+Plots work with one_click operations, e.g.
+
+```python
+from deephaven.parquet import read
+from deephaven.plot.selectable_dataset import one_click
+import deephaven.plot.express as dx
+
+source = read("/data/stocks.parquet")
+oc = one_click(t=source, by=["Sym"])
+
+# Support for SelectableDataSet in deephaven express is still WIP
+plot = dx.line(oc, x="Timestamp", y="Price")
+```
+
+The above still requires adding an Input Filter to the dashboard from the UI. You can also add an Input Filter from code, e.g.
+
+```python
+from deephaven.parquet import read
+from deephaven.plot.selectable_dataset import one_click
+import deephaven.plot.express as dx
+from deephaven import dtypes as dht
+
+source = read("/data/stocks.parquet")
+oc = one_click(t=source, by=["Sym"])
+
+# Support for SelectableDataSet in deephaven express is still WIP
+plot = dx.line(oc, x="Timestamp", y="Price")
+
+# Create an Input Filter control that filters on the "Sym" column of type string
+sym_filter = ui.input_filter(column="Sym", type=dht.string)
+```
+
+The above will add the plot and input filter to default locations in the dashboard. You can specify a dashboard layout if you want control of where the components are placed, e.g.
+
+```python
+d = ui.dashboard(ui.column(sym_filter, plot))
+```
+
+Along with the standard text Input Filter, you can add other types such as a Dropdown Filter:
+
+```python
+from deephaven.parquet import read
+from deephaven.plot.selectable_dataset import one_click
+import deephaven.plot.express as dx
+from deephaven import dtypes as dht
+
+source = read("/data/stocks.parquet")
+oc = one_click(t=source, by=["Sym"])
+
+# Support for SelectableDataSet in deephaven express is still WIP
+plot = dx.line(oc, x="Timestamp", y="Price")
+
+# Create a Dropdown Filter control that filters on the "Sym" column of type string
+sym_filter = ui.dropdown_filter(source=source, source_column="Sym")
+```
+
+You can put these in to a `@ui.component` function as well, if you wanted to have an input for the file path to read from, e.g.
+
+```python
+from deephaven.parquet import read
+from deephaven.plot.selectable_dataset import one_click
+import deephaven.plot.express as dx
+from deephaven import dtypes as dht
+
+
+@ui.component
+def my_oc_dash():
+    # Store the path in state so it can be changed
+    path, set_path = use_state("/data/stocks.parquet")
+
+    source = use_memo(lambda: read(path), [path])
+
+    oc = use_memo(lambda: one_click(t=source, by=["Sym"]), [path])
+
+    plot = use_memo(lambda: dx.line(oc, x="Timestamp", y="Price"), [oc])
+
+    sym_filter = ui.dropdown_filter(source=source, source_column="Sym")
+
+    # Dashboard where the top row is a text input for the path and input filter for Sym, then the resulting plot underneath
+    return ui.dashboard(
+        ui.column(
+            ui.row(ui.text_input(value=path, on_change=set_path), sym_filter), plot
+        )
+    )
+
+
+d = my_oc_dash()
+```
+
+#### Table Actions/Callbacks
+
+We want to be able to react to actions on the table as well. This can be achieved by adding a callback to the table, and used to set the state within our component. For example, if we want to filter a plot based on the selection in another table:
+
+![Alt text](./assets/on_row_clicked.png)
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def table_with_plot(source: Table, column: str = "Sym", default_value: str = ""):
+    value, set_value = ui.use_state(default_value)
+
+    # Wrap the table with an interactive component to listen to selections within the table
+    selectable_table = ui.use_memo(
+        lambda: interactive_table(
+            t=source,
+            # When data is selected, update the value
+            on_row_clicked=lambda event: set_value(event["data"][column]),
+        ),
+        [source],
+    )
+
+    # Create the plot by filtering the source using the currently selected value
+    p = ui.use_memo(
+        lambda: plot_xy(
+            t=source.where(f"{column}=`{value}`"), x="Timestamp", y="Price"
+        ),
+        [value],
+    )
+
+    return ui.flex([selectable_table, p])
+```
+
+OR could we add an attribute to the table instead? And a custom function on table itself to handle adding that attribute? E.g.:
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def table_with_plot(source: Table, column: str = "Sym", default_value: str = ""):
+    value, set_value = ui.use_state(default_value)
+
+    # Add the row clicked attribute
+    # equivalent to `selectable_table = t.with_attributes({'__on_row_clicked': my_func})`
+    selectable_table = source.on_row_clicked(
+        lambda event: set_value(event["data"][column])
+    )
+
+    # Create the plot by filtering the source using the currently selected value
+    p = ui.use_memo(
+        lambda: plot_xy(
+            t=source.where(f"{column}=`{value}`"), x="Timestamp", y="Price"
+        ),
+        [value],
+    )
+
+    return ui.flex([selectable_table, p])
+```
+
+#### Multiple Plots
+
+We can also use the same concept to have multiple plots, and have them all update based on the same input. For example, if we want to have two plots, one showing the "Last" price, and another showing the "Bid" price:
+
+![Alt text](./assets/multiple_plots.png)
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def two_plots(source: Table, column: str = "Sym", default_value: str = ""):
+    value, set_value = ui.use_state(default_value)
+
+    # Create the two plots by filtering the source using the currently selected value
+    p1 = ui.use_memo(
+        lambda: plot_xy(t=source.where(f"{column}=`{value}`"), x="Timestamp", y="Last"),
+        [value],
+    )
+    p2 = ui.use_memo(
+        lambda: plot_xy(t=source.where(f"{column}=`{value}`"), x="Timestamp", y="Bid"),
+        [value],
+    )
+
+    return ui.flex([p1, p2])
+```
+
+#### Text Input to Filter a Plot
+
+We can also use the same concept to have a text input field that filters a plot. For example, if we want to have a text input field that filters a plot based on the "Sym" column:
+
+![Alt text](./assets/text_input_plot.png)
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def text_input_plot(source: Table, column: str = "Sym"):
+    value, set_value = ui.use_state("")
+
+    # Create the plot by filtering the source using the currently selected value
+    # TODO: Is this link visible in the UI or just implicit?
+    p = ui.use_memo(
+        lambda: plot_xy(t=source.where(f"{column}=`{value}`"), x="Timestamp", y="Last"),
+        [value],
+    )
+
+    return ui.flex(
+        [
+            # Text input will update the value when it is changed
+            ui.text_input(
+                value=value, on_change=lambda event: set_value(event["value"])
+            ),
+            # Plot will be filtered/updated based on the above logic
+            p,
+        ]
+    )
+```
+
+#### Required Parameters
+
+Sometimes we want to require the user to enter a value before applying filtering operations. We can do this by adding a `required` label to the `text_input` itself, and then displaying a label instead of the table:
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def text_filter_table(source: Table, column: str):
+    value, set_value = ui.use_state("")
+
+    # Return a column that has the text input, then the table below it
+    return ui.flex(
+        [
+            ui.text_input(
+                value=value,
+                on_change=lambda event: set_value(event["value"]),
+                required=True,
+            ),
+            (
+                # Use Python ternary operator to only display the table if there has been a value entered
+                source.where(f"{column}=`{value}`")
+                if value
+                else ui.info("Please input a filter value")
+            ),
+        ]
+    )
+```
+
+Alternatively, we could have an overlay displayed on the table if an invalid filter is entered.
+
+#### Cross-Dependent Parameters (DH-15360)
+
+You can define parameters which are dependent on another parameter. You could define two range sliders for a low and high, for example:
+
+```python
+import deephaven.ui as ui
+
+
+@ui.component
+def two_sliders(min=0, max=10000):
+    lo, set_lo = use_state(min)
+    hi, set_hi = use_state(max)
+
+    # Use the `hi` currently set as the `max`. Will update automatically as `hi` is adjusted
+    s1 = ui.slider(value=lo, min=min, max=hi, on_change=set_lo)
+
+    # Use the `lo` currently set as the `min`. Will update automatically as `lo` is adjusted
+    s2 = ui.slider(value=hi, min=lo, max=max, on_change=set_hi)
+
+    return [s1, s2]
+```
+
+Or if you want a drop-down list that is dependent only on a filtered list of results from another table:
+
+```python
+@ui.component
+def filtered_accounts(source):
+    company, set_company = use_state("")
+    trader, set_trader = use_state("")
+
+    return [
+        # Use the distinct "Company" values as the possible options in the dropdown
+        ui.dropdown(source.select_distinct("Company")),
+        # Use the distinct "Trader" values after filtering the source by "Company"
+        ui.dropdown(source.where(f"Company={company}").select_distinct("Trader")),
+        # Show the table filtered on both "Company" and "Trader" selected
+        source.where([f"Company={company}", f"Trader={trader}"]),
+    ]
+```
+
+#### Multiple Queries (Enterprise only)
+
+We want to be able to pull in widgets/components from multiple queries. In DHC we have the [URI resolver](https://deephaven.io/core/docs/reference/uris/uri/) for resolving another resource, and should be able to extend that same functionality to resolve another PQ.
+
+```python
+# Persistent Query 'A'
+t = empty_table(100).update("a=i")
+
+# Persistent Query 'B'
+t = empty_table(100).update("b=i")
+
+# Executed in console session or a 3rd query
+import deephaven.ui as ui
+from deephaven.uri import resolve
+
+
+@ui.component
+def multi_query():
+    # Since the `resolve` method is only called from within a `@ui.component` wrapped function, it is only called when the component is actually rendered (e.g. opened in the UI)
+    # Note however this is still resolving the table on the server side, rather than the client fetching the table directly.
+    t1 = resolve("dh+plain://query-a:10000/scope/t")
+    t2 = resolve("dh+plain://query-b:10000/scope/t")
+    return [t1, t2]
+
+
+mq = multi_query()
+```
+
+We could also have a custom function defined such that an object will tell the UI what table to fetch; the downside of this is you would be unable to chain any table operations afterwards (NOTE: It _may_ be possible to build it such that we could do this, using QST and just having the UI apply an arbitrary set of operations defined by the QST afterwards? But may be tricky to build):
+
+```python
+# Persistent Query 'A'
+t = empty_table(100).update("a=i")
+
+# Persistent Query 'B'
+t = empty_table(100).update("b=i")
+
+# Executed in console session or a 3rd query
+import deephaven.ui as ui
+
+
+@ui.component
+def multi_query():
+    # Object that contains metadata about the table source, then UI client must fetch
+    t1 = ui.pq_table("Query A", "t")
+    t2 = ui.pq_table("Query B", "t")
+    return [t1, t2]
+
+
+mq = multi_query()
+```
+
+It may be that we want to do something interesting, such as defining the input in one query, and defining the output in another query.
+
+```python
+# Persistent Query 'A'
+import deephaven.ui as ui
+
+
+@ui.component
+def my_input(value, on_change):
+    return ui.text_input(value, on_change)
+
+
+# Persistent Query 'B'
+import deephaven.ui as ui
+
+
+@ui.component
+def my_output(value):
+    return empty_table(100).update(f"sym=`{value}`")
+
+
+# Executed in console session or a 3rd query
+import deephaven.ui as ui
+
+
+@ui.component
+def multi_query():
+    sym, set_sym = use_state("")
+
+    # TODO: Would this actually work? Resolving to a custom type defined in plugins rather than a simple table object
+    my_input = resolve("dh+plain://query-a:10000/scope/my_input")
+    my_output = resolve("dh+plain://query-b:10000/scope/my_output")
+
+    return [my_input(sym, set_sym), my_output(sym)]
+
+
+mq = multi_query()
+```
+
+#### Putting it all together
+
+Using the proposed components and selection listeners, you should be able to build pretty powerful components, and subsequently dashboards. For example, we could build a component that has the following:
+
+- Dual range slider for specifying the "Size" of trades to filter on
+- Table showing only the filtered range
+- Text input to filter a specific Sym for a plot derived from the table
+- Clicking a row within the table selects that Sym and updates the text input to reflect that
+- Clicking a data point in the plot will print out that data
+
+![Putting it all together](./assets/putting_it_all_together.png)
+
+```python
+import deephaven.ui as ui
+import deephaven.plot.express as dx
+
+
+@ui.component
+def stock_widget(source: Table, column: str = "Sym"):
+    lo, set_lo = use_state(0)
+    hi, set_hi = use_state(10000)
+    sym, set_sym = use_state("")
+
+    # Create the filtered table
+    filtered_table = ui.use_memo(
+        lambda: source.where([f"Price >= {lo} && Price <= {hi}"]), [lo, hi]
+    )
+
+    p = ui.use_memo(
+        lambda: dx.line(filtered_table.where(f"Sym=`{sym}`"), x="Timestamp", y="Last"),
+        [filtered_table],
+    )
+
+    def handle_slider_change(event):
+        set_lo(event.value.lo)
+        set_hi(event.value.hi)
+
+    return ui.flex(
+        [
+            # Slider will update the lo/hi values on changes
+            ui.range_slider(
+                lo=lo, hi=hi, min=0, max=10000, on_change=handle_slider_change
+            ),
+            # Wrap the filtered table so you can select a row
+            ui.interactive_table(
+                t=filtered_table,
+                # Update the Sym value when a row is selected
+                on_row_clicked=lambda event: set_sym(event["data"][column]),
+            ),
+            # Text input will update the sym when it is changed, or display the new value when selected from the table
+            ui.text_input(value=sym, on_change=lambda event: set_sym(event["value"])),
+            # Wrap the filtered plot so you can select data
+            ui.interactive_plot(
+                p=p, on_data_clicked=lambda event: print(f"data selected: {str(event)}")
+            ),
+        ]
+    )
+```
+
+#### Layouts/Dashboards
+
+The above examples focussed solely on defining components, all of which are simply rendered within one panel by default. Part of the ask is also about defining panels and dashboards/layouts. We use [Golden Layout](https://golden-layout.com/examples/), which defines all layouts in terms of placing Panels in [Rows, Columns and Stacks](https://golden-layout.com/tutorials/getting-started.html):
+
+- **Panel**: A panel with a tab header, containing one or more components. Can be moved around and resized within a dashboard.
+- **Row**: A row of panels arranged horizontally.
+- **Column**: A column of panels arranged vertically.
+- **Stack**: A stack of panels that overlap one another. Click the tab header to switch between them.
+- **Dashboard**: A layout of an entire dashboard
+
+We should be able to map these by using `ui.panel`, `ui.row`, `ui.column`, `ui.stack`, and `ui.dashboard`.
+
+##### ui.panel
+
+By default, the top level `@ui.component` will automatically be wrapped in a panel, so no need to define it unless you want custom panel functionality, such as giving the tab a custom name, e.g.:
+
+```python
+import deephaven.ui as ui
+
+# The only difference between this and `p = my_component()` is that the title of the panel will be set to `My Title`
+p = ui.panel(my_component(), title="My Title")
+```
+
+Note that a panel can only have one root component, and cannot be nested within other components (other than the layout ones `ui.row`, `ui.column`, `ui.stack`, `ui.dashboard`)
+
+TBD: How do you specify a title and/or tooltip for your panel? How do panels get a title or tooltip by default?
+
+##### ui.row, ui.column, ui.stack, ui.dashboard
+
+You can define a dashboard using these functions. By wrapping in a `ui.dashboard`, you are defining a whole dashboard. If you omit the `ui.dashboard`, it will add the layouts you've defined to the existing dashboard:
+
+- `ui.row` will add a new row of the panels defined at the bottom of the current dashboard
+- `ui.column` will add a new column of panels defined at the right of the current dashboard
+- `ui.stack` will add a new stack of panels at the next spot in the dashboard
+
+Defining these without a `ui.dashboard` is likely only going to be applicable to testing/iterating purposes, and in most cases you'll want to define the whole dashboard. For example, to define a dashboard with an input panel in the top left, a table in the top right, and a stack of plots across the bottom, you could define it like so:
+
+```python
+import deephaven.ui as ui
+
+# ui.dashboard takes only one root element
+d = ui.dashboard(
+    ui.column(
+        [
+            ui.row([my_input_panel(), my_table_panel()]),
+            ui.stack([my_plot1(), my_plot2()]),
+        ]
+    )
+)
+```
+
+Much like handling other components, you can do a prop/state thing to handle changing inputs/filtering appropriately:
+
+```python
+import deephaven.ui as ui
+
+# Need to add the `@ui.component` decorator so we can keep track of state
+@ui.component
+def my_dashboard():
+    value, set_value = use_state("")
+
+    return ui.dashboard(
+        ui.column(
+            [
+                ui.row(
+                    [
+                        my_input_panel(value=value, on_change=set_value),
+                        my_table_panel(value=value),
+                    ]
+                ),
+                ui.stack([my_plot1(value=value), my_plot2(value=value)]),
+            ]
+        )
+    )
+
+
+d = my_dashboard()
+```
+
+##### ui.link
+
+```python
+@ui.component
+def my_dashboard():
+    t1 = empty_table(100).update("a=i")
+    t2 = empty_table(100).update("b=i", "c=Math.sin(i)")
+
+    return ui.dashboard(
+        ui.row([ui.table(t1, _id="t1"), ui.table(t2, _id="t2")]),
+        links=[
+            ui.link(
+                start=ui.link_point("t1", column="a"),
+                end=ui.link_point("t2", column="b"),
+            )
+        ],
+    )
+
+
+d = my_dashboard()
+```
+
+#### Context
+
+By default, the context of a `@ui.component` will be created per client session (same as [Parameterized Query's "parallel universe" today](https://github.com/deephaven-ent/iris/blob/868b868fc9e180ee948137b10b6addbac043605e/ParameterizedQuery/src/main/java/io/deephaven/query/parameterized/impl/ParameterizedQueryServerImpl.java#L140)). However, it would be interesting if it were possible to share a context among all sessions for the current user, and/or share a context with other users even; e.g. if one user selects and applies a filter, it updates immediately for all other users with that dashboard open. So three cases:
+
+1. Limit to a particular client session (like Paramterized Queries, should likely be the default)
+2. Limit to the particular user (so if you have the same PQ open multiple tabs, it updates in all)
+3. Share with all users (if one user makes a change, all users see it)
+
+We can specify this by adding a `context` parameter to the `@ui.component` decorator:
+
+```python
+# Define a client session component, where execution will be scoped to the current client session
+# One user making a change will not be reflected to other tabs for that user
+# This will be the default and is equivalent to not specifying a context
+# @ui.component(context=ui.ContextType.CLIENT)
+# def client_component():
+# ... component details here
+
+# Define a user component, where execution will be scoped to the current user
+# One user making a change will be reflected to all tabs for that user
+# @ui.component(context=ui.ContextType.USER)
+# def user_component():
+# ... component details here
+
+# Define a shared component, where execution will be scoped to all users
+# One user making a change will be reflected to all users
+# @ui.component(context=ui.ContextType.SHARED)
+# def shared_component():
+# ... component details here
+```
+
+Note this could get interesting with nested components. It only makes sense to restrict the scope further when nesting, e.g. you can nest a `ui.ContextType.CLIENT` component within a `ui.ContextType.USER` component, but not the other way around. If you try to nest a `ui.ContextType.USER` component within a `ui.ContextType.CLIENT` component, it will throw an error:
+
+```python
+# This component will be scoped to the current client session
+@ui.component(context=ui.ContextType.CLIENT)
+def stock_table(source: Table):
+    sym, set_sym = use_state("AAPL")
+    t = source.where(f"sym=`{sym}`")
+    return [ui.text_input(value=sym, on_change=set_sym), t]
+
+
+# This component is scoped to the current user, so if they change the path in one tab, it will update in all other tabs
+@ui.component(context=ui.ContextType.USER)
+def user_path_component():
+    path, set_path = use_state("/data/stocks.parquet")
+    t = use_memo(lambda: read(path), [path])
+    # Using the `stock_table` component which is scoped to the current client session allows different filtering in different tabs
+    return [ui.text_input(value=path, on_change=set_path), stock_table(t)]
+
+
+result = user_path_component()
+
+# The other way around would throw an error though, e.g.
+@ui.component(context=ui.ContextType.CLIENT)
+def bad_scoping():
+    # Can't use `user_path_component` as that would expand the scope/context
+    return user_path_component()
+```
+
+Note: I think this is a stretch goal, and not planned for the initial implementation (but should be considered in the design). Only the default `ui.ContextType.CLIENT` will be supported initially.
+
+#### Problems
+
+Listing some problems and areas of concern that need further discussion.
+
+##### State Latency
+
+With callbacks, there will be a delay between when the user makes changes in the UI and when the state change is processed on the server. We can mitigate this with debounced input (e.g. when typing in a text input or changing a slider, debounce sending an update until they have stopped typing or sliding for a moment), and displaying some sort of "processing" state in the UI (e.g. a spinner or disabling the input) while the state is being updated. Optimistic updates are not possible, as there is no way for the UI to know what effect a particular state change will have on the output/other components. This is similar to what we do with Parameterized Queries currently, where we display a spinner in the "Submit" button while the query is being executed, and only after it returns do the output tables update.
+
+##### Language Compatibility
+
+The above examples are all in Python, and particularly take some advantage of language constructs in python (such as positional arguments and kwargs). We should consider how it would work in Groovy/Java as well, and how we can build one on top of the other.
+
+#### Architecture
+
+##### Rendering
+
+When you call a function decorated by `@ui.component`, it will return a `UiNode` object that has a reference to the function it is decorated; that is to say, the function does _not_ get run immediately. The function is only run when the `UiNode` is rendered by the client, and the result is sent back to the client. This allows the `@ui.component` decorator to execute the function with the appropriate rendering context, and also allows for memoization of the function (e.g. if the function is called multiple times with the same arguments, it will only be executed once - akin to a [memoized component](https://react.dev/reference/react/memo) or PureComponent in React).
+
+Let's say we execute the following, where a table is filtered based on the value of a text input:
+
+```python
+@ui.component
+def text_filter_table(source, column, initial_value=""):
+    value, set_value = use_state(initial_value)
+    ti = ui.text_field(value, on_change=set_value)
+    tt = source.where(f"{column}=`{value}`")
+    return [ti, tt]
+
+
+@ui.component
+def sym_exchange(source):
+    tft1 = text_filter_table(source, "Sym")
+    tft2 = text_filter_table(source, "Exchange")
+    return ui.flex(tft1, tft2, direction="row")
+
+
+import deephaven.plot.express as dx
+
+t = dx.data.stocks()
+
+tft = text_filter_table(t, "sym")
+```
+
+Which should result in a UI like this:
+
+![Double Text Filter Tables](examples/assets/double-tft.png)
+
+How does that look when the notebook is executed? When does each code block execute?
+
+```mermaid
+sequenceDiagram
+  participant U as User
+  participant W as Web UI
+  participant UIP as UI Plugin
+  participant C as Core
+  participant SP as Server Plugin
+
+  U->>W: Run notebook
+  W->>C: Execute code
+  C->>SP: is_type(object)
+  SP-->>C: Matching plugin
+  C-->>W: VariableChanges(added=[t, tft])
+
+  W->>UIP: Open tft
+  UIP->>C: Export tft
+  C-->>UIP: tft (UiNode)
+
+  Note over UIP: UI knows about object tft<br/>sym_exchange not executed yet
+
+  UIP->>SP: Render tft
+  SP->>SP: Run sym_exchange
+  Note over SP: sym_exchange executes, running text_filter_table twice
+  SP-->>UIP: Result (flex([tft1, tft2]))
+  UIP-->>W: Display (flex([tft1, tft2]))
+
+  U->>UIP: Change text input 1
+  UIP->>SP: Change state
+  SP->>SP: Run sym_exchange
+  Note over SP: sym_exchange executes, text_filter_table only <br/>runs once for the one changed input
+  SP-->>UIP: Result (flex([tft1', tft2]))
+  UIP-->>W: Display (flex([tft1', tft2]))
+```
+
+##### Communication/Callbacks
+
+When the document is first rendered, it will pass the entire document to the client. When the client makes a callback, it needs to send a message to the server indicating which callback it wants to trigger, and with which parameters. For this, we use [JSON-RPC](https://www.jsonrpc.org/specification). When the client opens the message stream to the server, the communication looks like:
+
+```mermaid
+sequenceDiagram
+  participant UIP as UI Plugin
+  participant SP as Server Plugin
+
+  UIP->>SP: obj.getDataAsString()
+    Note over UIP, SP: Uses json-rpc
+  SP-->>UIP: documentUpdated(Document)
+
+  loop Callback
+    UIP->>SP: foo(params)
+    SP-->>UIP: foo result
+    SP->>UIP: documentUpdated(Document)
+  end
+```
+
+##### Communication Layers
+
+A component that is created on the server side runs through a few steps before it is rendered on the client side:
+
+1. Element - The basis for all UI components. Generally a `FunctionElement`, and does not run the function until it is requested by the UI. The result can change depending on the context that it is rendered in (e.g. what "state" is set).
+2. RenderedNode - After an element has been rendered using a renderer, it becomes a `RenderedNode`. This is an immutable representation of the document.
+3. JSONEncodedNode - The `RenderedNode` is then encoded into JSON using `NodeEncoder`. It pulls out all the objects and maps them to exported objects, and all the callables to be mapped to commands that can be accepted by JSON-RPC. This is the final representation of the document that is sent to the client.
+4. ElementPanel - Client side where it's receiving the `documentUpdated` from the server plugin, and then rendering the `JSONEncodedNode` into a `ElementPanel` (e.g. a `GoldenLayout` panel). Decodes the JSON, maps all the exported objects to the actual objects, and all the callables to async methods that will call to the server.
+5. ElementView - Renders the decoded panel into the UI. Picks the element based on the name of it.
+6. ObjectView - Render an exported object
+
+#### Other Decisions
+
+While mocking this up, there are a few decisions regarding the syntax we should be thinking about/address prior to getting too far along with implementation.
+
+##### Module name
+
+The above examples use `deephaven.ui` for the module name. Another option would be `deephaven.layout`, but I thought this might get confusing with Golden Layout already existing.
+
+##### Structuring imports
+
+In the above example, there is one simple import, `import deephaven.ui as ui`. From there you just call `ui.component`, `ui.use_state`, etc.
+
+Another option would be importing items directly, e.g. `from deephaven.ui import component, use_state, range_slider`, etc.
+
+Or we could have some sort of hybrid:
+
+```python
+# Use `ui` import for components/elements
+import deephaven.ui as ui
+
+# Import hooks `use_` directly from `deephaven.ui`
+from deephaven.ui import use_state, use_memo
+
+# ... or even have a separate import for all hooks
+# import * from deephaven.ui.hooks
+```
+
+##### Decorators vs. Render function
+
+In React, it uses the `renderWithHooks` function internally to build a context. That's triggered by the `React.createElement` method, or more commonly via JSX when rendering the elements. Pushing/popping the context is crucial for maintaining the proper state and enabling hooks to work reliably.
+
+In Python, we do not have JSX available (or any such equivalent). The above examples use the `@ui.component` decorator for wrapping a function component:
+
+```python
+# Just using one source table, and allowing it to be filtered using two different filter inputs
+@ui.component
+def double_filter_table(source: Table, column: str):
+    return ui.flex(
+        [text_filter_table(source, column), text_filter_table(source, column)],
+        direction="row",
+    )
+
+
+dft = double_filter_table(source, "Sym")
+```
+
+Another option would be to require calling an explicit render function, e.g.:
+
+```python
+# Just using one source table, and allowing it to be filtered using two different filter inputs
+def double_filter_table(source: Table, column: str):
+    return ui.flex(
+        [
+            ui.render(text_filter_table(source, column)),
+            ui.render(text_filter_table(source, column)),
+        ],
+        direction="row",
+    )
+
+
+dft = ui.render(double_filter_table(source, "Sym"))
+```
+
+I think the decorator syntax is less verbose and more clear about how to use; especially when rendering/building a component composed of many other components. Calling `ui.render` to render all the children component seems problematic. Marking every possible component as just `@ui.component` is pretty straightforward, and should allow for easily embedding widgets.
+
+Note there was an interesting project for using [React Hooks in Python](https://github.com/amitassaraf/python-hooks). However, it is not recommended for production code and likely has some performance issues. It [inspects the call stack](https://github.com/amitassaraf/python-hooks/blob/main/src/hooks/frame_utils.py#L86) to manage hook state, which is kind of neat in that you don't need to wrap your functions; however that would come at performance costs, and also more difficult to be strict (e.g. requiring functions that use hooks to be wrapped in `@ui.component` - maybe there's other dev related things we want to do in there).
+
+##### Panel Titles/Tooltips
+
+- How do we preserve the behaviour of panel/tab tooltips for components?
+- How do we have components save their state?
+
+## Scheduling
+
+Breaking down the project schedule to be roughly:
+
+- **Phase 1 "Definition" (August)**: Distribute API syntax for discussion, gather feedback
+  - Bender gets a document together with examples mocking out the proposed syntax
+  - Solicit feedback from interested stakeholders on the proposed syntax and get agreement
+  - Rough Proof of Concept prototype built
+- **Phase 2 "Alpha" (September 4 - October 13, 6 weeks)**: Define custom components
+  - Create building blocks for defining custom components
+  - Python side (Joe):
+    - Create `deephaven.ui` module, testing
+    - Create render context/lifecycle
+      - Render into virtual object model (e.g. Virtual DOM)
+    - Create `@ui.component`, `use_state`, `use_memo` hooks, `ui.flex`, `ui.text_input`, `ui.slider` components
+    - Define/create messaging to send updates to client
+      - First send entire virtual DOM.
+      - Send updates for just the elements that are changed/updated (can start with just re-sending the whole document, but will need to break it down into just element updates afterwards).
+  - JavaScript side (Matt):
+    - Create `@deephaven/js-plugin-ui` JS plugin, wired up with testing
+    - Create `DashboardPlugin` to open up components created by `@ui.component`
+      - Render into one panel for now; multi-panel/dashboard layout comes in the next phase
+    - `ObjectPlugin` (`WidgetPlugin`? `ElementPlugin`? whatever the name) for plugins to wire up just displaying an object as an element (rather than all the panel wiring)
+      - `@deephaven/js-plugin-ui` needs to be able to render elements as defined in other `ObjectPlugin`s that are loaded.
+      - `ObjectPlugin`s that match `ui.flex`, `ui.text_input`, `ui.slider` elements
+    - Handle updates sent from the server
+    - Update Linker to allow setting links between components (instead of just panels)
+    - Handle dehydrating/rehydrating of components
+    - Release "Alpha"
+- **Phase 3 "Beta" (October 16 - November 17, 5 weeks):** Define layouts/dashboards
+  - Python side (Joe):
+    - Create `@ui.panel`, `@ui.dashboard` components?
+  - JavaScript side (Matt):
+    - Handle opening up `@ui.panel` in a dashboard?
+  - Gather feedback from actual usage
+    - Fix any critical bugs
+    - Incorporate feedback when possible, or record for later implementation in Phase 4 and beyond
+  - Release "Beta"
+- **Phase 4 (November 20 - December 22, 5 weeks):** Polish
+  - Fix any bugs that are identified
+  - Lots of testing
+  - Add any additional components that are requested based on feedback from previous phases
+  - Release "Production"
+
+## Using a list and keys
+
+We can add hooks for retrieving a snapshot or list of data from a table and using it within code. For example, if we wanted to display a list of users:
+
+```python
+@ui.component
+def user_list():
+    # Get the user table using a hook
+    user_table = use_table("Company", "Users")
+
+    # Use a snapshot of the table to get a list of users
+    users = use_snapshot(user_table, ["Name", "Email", "ID"])
+
+    return ui.flex(
+        [
+            # Use a list of users to render a list of user components
+            # The `_key` kwarg is used to uniquely identify each component in the list.
+            # This can save on re-renders when the list is updated, even if items change order.
+            map(
+                lambda user: ui.text(
+                    f"{user['Name']}: {user['Email']}", _key=user["ID"]
+                ),
+                users,
+            )
+        ]
+    )
+
+
+ul = user_list()
+```
+
+## Converting a Parameterized Query
+
+Taking the [example Parameterized Query from our docs](https://deephaven.io/enterprise/docs/development/parameterized-queries/):
+
+```groovy
+import io.deephaven.query.parameterized.ParameterizedQuery
+import io.deephaven.query.parameterized.Parameter
+
+myQuery = ParameterizedQuery.create()
+    .with(Parameter.ofLong("intParam").inRange(0,100))
+    .with(Parameter.ofString("stringParam"))
+    .with(Parameter.ofDouble("doubleParam").inRange(-1.0, 1.0).optional())
+    .willDo({scope ->
+        def intParam = scope.getInteger("intParam")
+        def stringParam = scope.getString("stringParam")
+
+        // Get the authenticated or effective ('run as') user names of the person or
+        // process executing this query.
+        def authenticatedUser = scope.getAuthenticatedUser()
+        def effectiveUser = scope.getEffectiveUser()
+
+        // Get the groups of the effective user.
+        def groups = scope.getPermissionFilterProvider().getGroupsForUser(scope.getUserContext())
+
+                // Using db.i() or db.t() will return results in the context of the query's owner, not the executor.
+        // You may want to perform this fetch with the owner's elevated permissions, for example, to produce
+        // summaries that can be more broadly shared. The query writer is responsible for ensuring that they
+        // are not returning sensitive data to viewers.
+        def tableResultAsOwner = db.i("SomeNamespace", "SomeTable")
+
+        // Using the fetchTableIntraday() or fetchTableHistorical() from the scope will return results in the
+        // context of the query's executor, which will apply the permissions for that user. You can then safely
+        // display the resulting table to that user.
+        def tableResult = scope.fetchTableIntraday("SomeNamespace", "SomeTable")
+
+        def tableResult = tableResult.where("Date=currentDateNy()", "StringCol=`$stringParam`")
+            .updateView("OffsetInt = IntCol + $intParam", "Scaled = (double)OffsetInt")
+
+        if (scope.isPresent("doubleParam")) {
+            def maybeDoubleParam = scope.getDouble("doubleParam")
+            tableResult = tableResult.updateView("Scaled = OffsetInt * $maybeDoubleParam")
+        }
+
+        plotResult = plot("Stuff", tableResult, "Timestamp", "Scaled").show()
+
+        scope.setResult("tableResult", tableResult)
+        scope.setResult("plotResult", plotResult)
+    }).build()
+```
+
+Building the same thing using `deephaven.ui`:
+
+```python
+import deephaven.ui as ui
+from deephaven.ui.hooks import *
+import deephaven.plot.express as dx
+
+
+@ui.component
+def my_query_component():
+    # Store the state for our parameters
+    int_param, set_int_param = use_state(0)
+    string_param, set_string_param = use_state("")
+    double_param, set_double_param = use_state(0.0)
+    is_double_used, set_is_double_used = use_state(False)
+
+    # Hooks for getting the authenticated/effective user names and groups
+    authenticated_user = use_authenticated_user()
+    effective_user = use_effective_user()
+    groups = use_groups()
+
+    # Hooks for getting tables. Pass in an optional `as_owner` to fetch as query owner
+    table_result_as_owner = use_intraday_table(
+        "SomeNamespace", "SomeTable", as_owner=True
+    )
+    table_result = use_intraday_table("SomeNamespace", "SomeTable")
+
+    # Apply the parameters
+    table_result = table_result.where(
+        f"Date=currentDateNy()", f"StringCol=`{string_param}`"
+    ).update_view(f"OffsetInt = IntCol + {int_param}", "Scaled = (double)OffsetInt")
+
+    # Apply the double_param if it is used
+    if is_double_used:
+        table_result = table_result.update_view(f"Scaled = OffsetInt * {double_param}")
+
+    plot_result = dx.line(table=table_result, x="Timestamp", y="Scaled")
+
+    return [
+        # One panel for all the inputs
+        ui.panel(
+            [
+                # For each kind of input, we can specify the control we want to use (e.g. slider, text input, checkbox)
+                ui.slider(value=my_int, on_change=on_int_change, min=0, max=100),
+                ui.text_input(value=my_str, on_change=on_str_change),
+                ui.checkbox(
+                    "Use double?", value=is_double_used, on_change=set_is_double_used
+                ),
+                ui.slider(
+                    value=double_param,
+                    on_change=set_double_param,
+                    min=-1.0,
+                    max=1.0,
+                    disabled=not is_double_used,
+                ),
+            ]
+        ),
+        # One panel for the table
+        ui.panel(table_result),
+        # One panel for the plot
+        ui.panel(plot("Stuff", table_result, "Timestamp", "Scaled").show()),
+    ]
+
+
+my_query = my_query_component()
+```
+
+## Glossary
+
+- **Programmatic Layouts**: The concept of being able to programmatically define how output from a command will appear in the UI.
+- **Callbacks**: Programmatically defined functions that will execute when an action is taken in the UI (e.g. inputting text, selecting a row in a table)
+- **Widget**: Custom objects defined on the server. Defined by `LiveWidget`, only implemented by our native `Figure` object right now.
+- **ObjectType Plugin**: A plugin defined for serializing custom objects between server/client.
+- **deephaven.ui**: Proposed name of the module containing the programmatic layout/callback functionality
+- **Component**: Denoted by `@ui.component` decorator, a Functional Component programmatically defined with a similar rendering lifecycle as a [React Functional Component](https://react.dev/learn#components). (Note: Might be more proper to name it `Element` and denote with `@ui.element`)
diff --git a/plugins/ui/LICENSE b/plugins/ui/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/plugins/ui/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/plugins/ui/README.md b/plugins/ui/README.md
new file mode 100644
index 000000000..d83902498
--- /dev/null
+++ b/plugins/ui/README.md
@@ -0,0 +1,62 @@
+# ⚠️This plugin is a work in progress and is subject to change. Use at your own risk.⚠️
+
+# deephaven.ui Plugin
+
+Plugin prototype for programmatic layouts and callbacks. Currently calling it `deephaven.ui` but that's not set in stone.
+
+## Build
+
+To create your build / development environment (skip the first two lines if you already have a venv):
+
+```sh
+python -m venv .venv
+source .venv/bin/activate
+pip install --upgrade pip setuptools
+pip install build deephaven-plugin plotly
+```
+
+To build:
+
+```sh
+python -m build --wheel
+```
+
+The wheel is stored in `dist/`.
+
+To test within [deephaven-core](https://github.com/deephaven/deephaven-core), note where this wheel is stored (using `pwd`, for example).
+Then, follow the directions in the top-level README.md to install the wheel into your Deephaven environment.
+
+To unit test, run the following command from the root of the repo:
+
+```sh
+tox -e py
+```
+
+## Usage
+
+Once you have the JS and python plugins installed and the server started, you can use deephaven.ui. See [EXAMPLES.md](EXAMPLES.md) for examples.
+
+## Logging
+
+The Python library uses the [logging](https://docs.python.org/3/howto/logging.html) module to log messages. The default log level is `WARNING`. To change the log level for debugging, set the log level to `DEBUG`:
+
+```python
+import logging
+import sys
+
+# Have the root logger output to stdout instead of stderr
+logging.basicConfig(stream=sys.stdout, level=logging.WARNING)
+
+# Set the log level for the deephaven.ui logger to DEBUG
+logging.getLogger("deephaven.ui").setLevel(level=logging.DEBUG)
+```
+
+You can also set the log level for specific modules if you want to see specific modules' debug messages or filter out other ones, e.g.
+
+```python
+# Only log warnings from deephaven.ui.hooks
+logging.getLogger("deephaven.ui.hooks").setLevel(level=logging.WARNING)
+
+# Log all debug messages from the render module specifically
+logging.getLogger("deephaven.ui.render").setLevel(level=logging.DEBUG)
+```
diff --git a/plugins/ui/examples/README.md b/plugins/ui/examples/README.md
new file mode 100644
index 000000000..7fed17914
--- /dev/null
+++ b/plugins/ui/examples/README.md
@@ -0,0 +1,494 @@
+# ⚠️This plugin is a work in progress and is subject to change. Use at your own risk.⚠️
+
+# Introduction
+
+deephaven.ui is a plugin for Deephaven that allows for programmatic layouts and callbacks. It uses a React-like approach to building components and rendering them in the UI, allowing for creating reactive components that can be re-used and composed together, as well as reacting to user input from the UI.
+
+Below are some examples to demonstrate some of the functionality you can do so far with deephaven.ui. At this point it is only showcasing the subset of the planned functionality that has been implemented, but should give an idea of what is possible. Most notably, all examples will only appear within one panel in the UI, the `ui.table` functionality (allowing interactivity and customization of displayed tables), and ability to use other plugins (such as deephaven.plot.express) is not yet implemented.
+
+You can run the example Docker container with the following command:
+
+```
+docker run --rm --name deephaven-ui -p 10000:10000 ghcr.io/deephaven/server-ui:edge
+```
+
+You'll need to find the link to open the UI in the Docker logs:
+![docker](assets/docker.png)
+
+# Basic `use_state` Examples
+
+deephaven.ui uses functional components with "hooks" to create components. The most useful and basic hook is the `use_state` hook, which allows you to create a stateful component. The `use_state` hook returns a tuple of the current value of the state and a function to update the state. The function returned by `use_state` can be called with a new value to update the state, and the component will re-render with the new value. People familiar with React will be familiar with this paradigm.
+
+The below examples show a simple usage of the `use_state` hook, building some of the basic examples on the [React useState docs](https://react.dev/reference/react/useState#examples-basic).
+
+## Counter (number)
+
+A simple example to demonstrate how state can be used using the `use_state` hook. `count` holds the value of the counter, and pressing the button increments the number.
+
+We define our `counter` component as a function using the `@ui.component` decorator. This decorator allows the component to be rendered in the UI, when we assign the result of it to a value with the `c = counter()` line. The `counter` function returns a `ui.action_button` component, which is a [button that can be pressed](https://react-spectrum.adobe.com/react-spectrum/ActionButton.html). The `on_press` argument is a callback that is called when the button is pressed. In this case, we call the `set_count` function returned by `use_state` to update the value of `count`.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def counter():
+    count, set_count = use_state(0)
+    return ui.action_button(
+        f"You pressed me {count} times", on_press=lambda: set_count(count + 1)
+    )
+
+
+c = counter()
+```
+
+![Counter](assets/counter.png)
+
+## Text field (string)
+
+You can create a [TextField](https://react-spectrum.adobe.com/react-spectrum/TextField.html) that takes input from the user.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def my_input():
+    text, set_text = use_state("hello")
+
+    return [ui.text_field(value=text, on_change=set_text), ui.text(f"You typed {text}")]
+
+
+mi = my_input()
+```
+
+![Text Field](assets/text_field.png)
+
+## Checkbox (boolean)
+
+You can use a [checkbox](https://react-spectrum.adobe.com/react-spectrum/Checkbox.html) to get a boolean value from the user.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def checkbox_example():
+    liked, set_liked = use_state(True)
+    return [
+        ui.checkbox("I liked this", is_selected=liked, on_change=set_liked),
+        ui.text("You liked this" if liked else "You didn't like this"),
+    ]
+
+
+ce = checkbox_example()
+```
+
+![Checkbox](assets/checkbox.png)
+
+## Form (two variables)
+
+You can have state with multiple different variables in one component. In this example, we have a [text field](https://react-spectrum.adobe.com/react-spectrum/TextField.html) and a [slider](https://react-spectrum.adobe.com/react-spectrum/Slider.html), and we display the values of both of them.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def form_example():
+    name, set_name = use_state("Homer")
+    age, set_age = use_state(36)
+
+    return [
+        ui.text_field(value=name, on_change=set_name),
+        ui.slider(value=age, on_change=set_age),
+        ui.text(f"Hello {name}, you are {age} years old"),
+    ]
+
+
+fe = form_example()
+```
+
+# Data Examples
+
+Many of the examples below use the stocks table provided by `deephaven.plot.express` package:
+
+```python
+import deephaven.plot.express as dx
+
+stocks = dx.data.stocks()
+```
+
+## Table with input filter
+
+You can take input from a user to filter a table using the `where` method. In this example, we have a [text field](https://react-spectrum.adobe.com/react-spectrum/TextField.html) that takes input from the user, and we filter the table based on the input. By simply returning the table `t` from the component, it will be displayed in the UI (as if we had set it to a variable name).
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def text_filter_table(source, column):
+    value, set_value = use_state("FISH")
+    t = source.where(f"{column}=`{value}`")
+    return [ui.text_field(value=value, on_change=set_value), t]
+
+
+pp = text_filter_table(stocks, "sym")
+```
+
+![Text Filter Table](assets/text_filter_table.png)
+
+## Table with required filters
+
+In the previous example, we took a users input. But we didn't display anything if the user didn't enter any text. We can display a different message prompting the user for input if they haven't entered anything. We use a few new components in this example:
+
+- [IllustratedMessage](https://react-spectrum.adobe.com/react-spectrum/IllustratedMessage.html) (ui.illustrated_message): A component that displays an icon, heading, and content. In this case, we display a warning icon, a heading, and some content.
+- [Icon](https://react-spectrum.adobe.com/react-spectrum/Icon.html) (ui.icon): A component that displays an icon. In this case, we display the warning icon, and set the font size to 48px so it appears large in the UI.
+- [Flex](https://react-spectrum.adobe.com/react-spectrum/Flex.html) (ui.flex): A component that displays its children in a row. In this case, we display the input text fields beside eachother in a row.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def stock_widget_table(source, default_sym="", default_exchange=""):
+    sym, set_sym = use_state(default_sym)
+    exchange, set_exchange = use_state(default_exchange)
+
+    ti1 = ui.text_field(
+        label="Sym", label_position="side", value=sym, on_change=set_sym
+    )
+    ti2 = ui.text_field(
+        label="Exchange", label_position="side", value=exchange, on_change=set_exchange
+    )
+    error_message = ui.illustrated_message(
+        ui.icon("vsWarning", style={"fontSize": "48px"}),
+        ui.heading("Invalid Input"),
+        ui.content("Please enter 'Sym' and 'Exchange' above"),
+    )
+    t1 = (
+        source.where([f"sym=`{sym.upper()}`", f"exchange=`{exchange.upper()}`"])
+        if sym and exchange
+        else error_message
+    )
+
+    return [ui.flex(ti1, ti2), t1]
+
+
+swt = stock_widget_table(stocks, "", "")
+```
+
+![Stock Widget Table Invalid Input](assets/stock_widget_table_invalid.png)
+
+![Stock Widget Table Valid Input](assets/stock_widget_table_valid.png)
+
+## Plot with filters
+
+You can also do plots as you would expect.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+from deephaven.plot.figure import Figure
+
+
+@ui.component
+def stock_widget_plot(source, default_sym="", default_exchange=""):
+    sym, set_sym = use_state(default_sym)
+    exchange, set_exchange = use_state(default_exchange)
+
+    ti1 = ui.text_field(
+        label="Sym", label_position="side", value=sym, on_change=set_sym
+    )
+    ti2 = ui.text_field(
+        label="Exchange", label_position="side", value=exchange, on_change=set_exchange
+    )
+    t1 = source.where([f"sym=`{sym.upper()}`", f"exchange=`{exchange}`"])
+    p = (
+        Figure()
+        .plot_xy(series_name=f"{sym}-{exchange}", t=t1, x="timestamp", y="price")
+        .show()
+    )
+
+    return [ui.flex(ti1, ti2), t1, p]
+
+
+swp = stock_widget_plot(stocks, "CAT", "TPET")
+```
+
+![Stock Widget Plot](assets/stock_widget_plot.png)
+
+# Other Examples
+
+## Memoization
+
+We can use the `use_memo` hook to memoize a value. This is useful if you have a value that is expensive to compute, and you only want to compute it when the inputs change. In this example, we create a time table with a new column `y_sin` that is a sine wave. We use `use_memo` to memoize the time table, so that it is only re-computed when the inputs to the `use_memo` function change (in this case, the function is a lambda that takes no arguments, so it will only re-compute when the dependencies change, which is never). We then use the `update` method to update the table with the new column, based on the values inputted on the sliders.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_memo, use_state
+from deephaven import time_table
+
+
+@ui.component
+def waves():
+    amplitude, set_amplitude = use_state(1)
+    frequency, set_frequency = use_state(1)
+    phase, set_phase = use_state(1)
+
+    tt = use_memo(lambda: time_table("PT1s").update("x=i"), [])
+    t = tt.update_view([f"y_sin={amplitude}*Math.sin({frequency}*x+{phase})"])
+
+    return ui.flex(
+        ui.flex(
+            ui.slider(
+                label="Amplitude",
+                default_value=amplitude,
+                min_value=-100,
+                max_value=100,
+                on_change=set_amplitude,
+            ),
+            ui.slider(
+                label="Frequency",
+                default_value=frequency,
+                min_value=-100,
+                max_value=100,
+                on_change=set_frequency,
+            ),
+            ui.slider(
+                label="Phase",
+                default_value=phase,
+                min_value=-100,
+                max_value=100,
+                on_change=set_phase,
+            ),
+            direction="column",
+        ),
+        t,
+        flex_grow=1,
+    )
+
+
+w = waves()
+```
+
+![Waves](assets/waves.png)
+
+## Custom hook
+
+We can write custom hooks that can be re-used. In this example, we create a custom hook that creates an input panel that controls the amplitude, frequency, and phase for a wave. We then use this custom hook in our `waves` component.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_memo, use_state
+from deephaven import time_table
+
+
+def use_wave_input():
+    """
+    Demonstrating a custom hook.
+    Creates an input panel that controls the amplitude, frequency, and phase for a wave
+    """
+    amplitude, set_amplitude = use_state(1.0)
+    frequency, set_frequency = use_state(1.0)
+    phase, set_phase = use_state(1.0)
+
+    input_panel = ui.flex(
+        ui.slider(
+            label="Amplitude",
+            default_value=amplitude,
+            min_value=-100.0,
+            max_value=100.0,
+            on_change=set_amplitude,
+            step=0.1,
+        ),
+        ui.slider(
+            label="Frequency",
+            default_value=frequency,
+            min_value=-100.0,
+            max_value=100.0,
+            on_change=set_frequency,
+            step=0.1,
+        ),
+        ui.slider(
+            label="Phase",
+            default_value=phase,
+            min_value=-100.0,
+            max_value=100.0,
+            on_change=set_phase,
+            step=0.1,
+        ),
+        direction="column",
+    )
+
+    return amplitude, frequency, phase, input_panel
+
+
+@ui.component
+def waves():
+    amplitude, frequency, phase, wave_input = use_wave_input()
+
+    tt = use_memo(lambda: time_table("PT1s").update("x=i"), [])
+    t = tt.update([f"y_sin={amplitude}*Math.sin({frequency}*x+{phase})"])
+
+    return ui.flex(wave_input, t, flex_grow=1)
+
+
+w = waves()
+```
+
+![Wave Input](assets/wave_input.png)
+
+We can then re-use that hook to make a component that displays a plot as well:
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_memo
+from deephaven.plot.figure import Figure
+
+
+@ui.component
+def waves_with_plot():
+    amplitude, frequency, phase, wave_input = use_wave_input()
+
+    tt = use_memo(lambda: time_table("PT1s").update("x=i"), [])
+    t = use_memo(
+        lambda: tt.update(
+            [
+                f"y_sin={amplitude}*Math.sin({frequency}*x+{phase})",
+            ]
+        ),
+        [amplitude, frequency, phase],
+    )
+    p = use_memo(
+        lambda: Figure().plot_xy(series_name="Sine", t=t, x="x", y="y_sin").show(), [t]
+    )
+
+    return ui.flex(wave_input, ui.flex(t, max_width=200), p, flex_grow=1)
+
+
+wp = waves_with_plot()
+```
+
+![Waves with plot](assets/waves_with_plot.png)
+
+## Re-using components
+
+In a previous example, we created a text_filter_table component. We can re-use that component, and display two tables with an input filter side-by-side:
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_state
+
+
+@ui.component
+def text_filter_table(source, column, default_value=""):
+    value, set_value = use_state(default_value)
+    return ui.flex(
+        ui.text_field(
+            label=column, label_position="side", value=value, on_change=set_value
+        ),
+        source.where(f"{column}=`{value}`"),
+        direction="column",
+        flex_grow=1,
+    )
+
+
+@ui.component
+def double_table(source):
+    return ui.flex(
+        text_filter_table(source, "sym", "FISH"),
+        text_filter_table(source, "exchange", "PETX"),
+        flex_grow=1,
+    )
+
+
+dt = double_table(stocks)
+```
+
+![Double Table](assets/double_table.png)
+
+## Stock rollup
+
+You can use the `rollup` method to create a rollup table. In this example, we create a rollup table that shows the average price of each stock and/or exchange. You can toggle the rollup by clicking on the [ToggleButton](https://react-spectrum.adobe.com/react-spectrum/ToggleButton.html). You can also highlight a specific stock by entering the symbol in the text field.
+
+```python
+import deephaven.ui as ui
+from deephaven.ui import use_memo, use_state
+from deephaven import agg
+import deephaven.plot.express as dx
+
+stocks = dx.data.stocks()
+
+
+def get_by_filter(**byargs):
+    """
+    Gets a by filter where the arguments are all args passed in where the value is true.
+
+    Examples:
+        get_by_filter(sym=True, exchange=False) == ["sym"]
+        get_by_filter(exchange=False) == []
+        get_by_filter(sym=True, exchange=True) == ["sym", "exchange"]
+
+    """
+    return [k for k in byargs if byargs[k]]
+
+
+@ui.component
+def stock_table(source):
+    is_sym, set_is_sym = use_state(False)
+    is_exchange, set_is_exchange = use_state(False)
+    highlight, set_highlight = use_state("")
+    aggs, set_aggs = use_state(agg.avg(cols=["size", "price", "dollars"]))
+
+    by = get_by_filter(sym=is_sym, exchange=is_exchange)
+
+    formatted_table = use_memo(
+        lambda: source.format_row_where(f"sym=`{highlight}`", "LEMONCHIFFON"),
+        [source, highlight],
+    )
+    rolled_table = use_memo(
+        lambda: formatted_table
+        if len(by) == 0
+        else formatted_table.rollup(aggs=aggs, by=by),
+        [formatted_table, aggs, by],
+    )
+
+    return ui.flex(
+        ui.flex(
+            ui.toggle_button(ui.icon("vsSymbolMisc"), "By Sym", on_change=set_is_sym),
+            ui.toggle_button(
+                ui.icon("vsBell"), "By Exchange", on_change=set_is_exchange
+            ),
+            ui.text_field(
+                label="Highlight Sym",
+                label_position="side",
+                value=highlight,
+                on_change=set_highlight,
+            ),
+            ui.contextual_help(
+                ui.heading("Highlight Sym"),
+                ui.content("Enter a sym you would like highlighted."),
+            ),
+            align_items="center",
+            gap="size-100",
+            margin="size-100",
+            margin_bottom="0",
+        ),
+        rolled_table,
+        direction="column",
+        flex_grow=1,
+    )
+
+
+st = stock_table(stocks)
+```
+
+![Stock Rollup](assets/stock_rollup.png)
diff --git a/plugins/ui/examples/assets/checkbox.png b/plugins/ui/examples/assets/checkbox.png
new file mode 100644
index 000000000..52d71ac6c
Binary files /dev/null and b/plugins/ui/examples/assets/checkbox.png differ
diff --git a/plugins/ui/examples/assets/counter.png b/plugins/ui/examples/assets/counter.png
new file mode 100644
index 000000000..3fad032f0
Binary files /dev/null and b/plugins/ui/examples/assets/counter.png differ
diff --git a/plugins/ui/examples/assets/docker.png b/plugins/ui/examples/assets/docker.png
new file mode 100644
index 000000000..4bf82f5f5
Binary files /dev/null and b/plugins/ui/examples/assets/docker.png differ
diff --git a/plugins/ui/examples/assets/double-tft.png b/plugins/ui/examples/assets/double-tft.png
new file mode 100644
index 000000000..a70f8ab4c
Binary files /dev/null and b/plugins/ui/examples/assets/double-tft.png differ
diff --git a/plugins/ui/examples/assets/double_table.png b/plugins/ui/examples/assets/double_table.png
new file mode 100644
index 000000000..dcb97625d
Binary files /dev/null and b/plugins/ui/examples/assets/double_table.png differ
diff --git a/plugins/ui/examples/assets/stock_rollup.png b/plugins/ui/examples/assets/stock_rollup.png
new file mode 100644
index 000000000..a110c5fca
Binary files /dev/null and b/plugins/ui/examples/assets/stock_rollup.png differ
diff --git a/plugins/ui/examples/assets/stock_widget_plot.png b/plugins/ui/examples/assets/stock_widget_plot.png
new file mode 100644
index 000000000..3302c2653
Binary files /dev/null and b/plugins/ui/examples/assets/stock_widget_plot.png differ
diff --git a/plugins/ui/examples/assets/stock_widget_table_invalid.png b/plugins/ui/examples/assets/stock_widget_table_invalid.png
new file mode 100644
index 000000000..2377707de
Binary files /dev/null and b/plugins/ui/examples/assets/stock_widget_table_invalid.png differ
diff --git a/plugins/ui/examples/assets/stock_widget_table_valid.png b/plugins/ui/examples/assets/stock_widget_table_valid.png
new file mode 100644
index 000000000..7fdecb551
Binary files /dev/null and b/plugins/ui/examples/assets/stock_widget_table_valid.png differ
diff --git a/plugins/ui/examples/assets/text_field.png b/plugins/ui/examples/assets/text_field.png
new file mode 100644
index 000000000..d196ba108
Binary files /dev/null and b/plugins/ui/examples/assets/text_field.png differ
diff --git a/plugins/ui/examples/assets/text_filter_table.png b/plugins/ui/examples/assets/text_filter_table.png
new file mode 100644
index 000000000..143c1671f
Binary files /dev/null and b/plugins/ui/examples/assets/text_filter_table.png differ
diff --git a/plugins/ui/examples/assets/wave_input.png b/plugins/ui/examples/assets/wave_input.png
new file mode 100644
index 000000000..5ceb20525
Binary files /dev/null and b/plugins/ui/examples/assets/wave_input.png differ
diff --git a/plugins/ui/examples/assets/waves.png b/plugins/ui/examples/assets/waves.png
new file mode 100644
index 000000000..06658a308
Binary files /dev/null and b/plugins/ui/examples/assets/waves.png differ
diff --git a/plugins/ui/examples/assets/waves_with_plot.png b/plugins/ui/examples/assets/waves_with_plot.png
new file mode 100644
index 000000000..3101cdbbc
Binary files /dev/null and b/plugins/ui/examples/assets/waves_with_plot.png differ
diff --git a/plugins/ui/pyproject.toml b/plugins/ui/pyproject.toml
new file mode 100644
index 000000000..62df2b006
--- /dev/null
+++ b/plugins/ui/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=43.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/plugins/ui/setup.cfg b/plugins/ui/setup.cfg
new file mode 100644
index 000000000..358cd3c6f
--- /dev/null
+++ b/plugins/ui/setup.cfg
@@ -0,0 +1,37 @@
+[metadata]
+name = deephaven-plugin-ui
+description = deephaven.ui plugin
+long_description = file: README.md
+long_description_content_type = text/markdown
+version = attr:deephaven.ui.__version__
+url = https://github.com/deephaven/deephaven-plugins
+project_urls =
+    Source Code = https://github.com/deephaven/deephaven-plugins
+    Bug Tracker = https://github.com/deephaven/deephaven-plugins/issues
+classifiers =
+    Programming Language :: Python :: 3
+    License :: OSI Approved :: Apache Software License
+    Operating System :: OS Independent
+    Environment :: Plugins
+    Topic :: Scientific/Engineering :: Visualization
+    Development Status :: 3 - Alpha
+keywords = deephaven, plugin, graph
+author = Deephaven Data Labs
+author_email = support@deephaven.io
+platforms = any
+
+[options]
+package_dir=
+    =src
+packages=find_namespace:
+install_requires =
+    deephaven-plugin
+    json-rpc
+include_package_data = True
+
+[options.packages.find]
+where=src
+
+[options.entry_points]
+deephaven.plugin =
+    registration_cls = deephaven.ui:UIRegistration
diff --git a/plugins/ui/src/deephaven/ui/__init__.py b/plugins/ui/src/deephaven/ui/__init__.py
new file mode 100644
index 000000000..f4e4b40a2
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/__init__.py
@@ -0,0 +1,18 @@
+"""
+deephaven.ui is a plugin for Deephaven that provides a Python API for creating UIs.
+
+The API is designed to be similar to React, but with some differences to make it more Pythonic.
+"""
+
+from deephaven.plugin import Registration, Callback
+from .components import *
+from .hooks import *
+from .object_types import *
+
+__version__ = "0.0.1.dev1"
+
+
+class UIRegistration(Registration):
+    @classmethod
+    def register_into(cls, callback: Callback) -> None:
+        callback.register(ElementType)
diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py
new file mode 100644
index 000000000..496f4ad11
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py
@@ -0,0 +1,128 @@
+import logging
+from typing import Any, Callable
+from contextlib import AbstractContextManager
+
+logger = logging.getLogger(__name__)
+
+OnChangeCallable = Callable[[], None]
+StateKey = int
+ContextKey = str
+
+
+class RenderContext(AbstractContextManager):
+    """
+    Context for rendering a component. Keeps track of state and child contexts.
+    Used by hooks to get and set state.
+    """
+
+    _hook_index: int
+    """
+    The index of the current hook for this render. Should only be set while rendering.
+    """
+
+    _hook_count: int
+    """
+    Count of hooks used in the render. Should only be set after initial render.
+    """
+
+    _state: dict[StateKey, Any]
+    """
+    The state for this context.
+    """
+
+    _children_context: dict[ContextKey, "RenderContext"]
+    """
+    The child contexts for this context. 
+    """
+    _on_change: OnChangeCallable
+    """
+    The on_change callback to call when the context changes.
+    """
+
+    def __init__(self):
+        self._hook_index = -1
+        self._hook_count = -1
+        self._state = {}
+        self._children_context = {}
+        self._on_change = lambda: None
+
+    def __enter__(self) -> None:
+        """
+        Start rendering this component.
+        """
+        self._hook_index = -1
+
+    def __exit__(self, type, value, traceback) -> None:
+        """
+        Finish rendering this component.
+        """
+        hook_count = self._hook_index + 1
+        if self._hook_count < 0:
+            self._hook_count = hook_count
+        elif self._hook_count != hook_count:
+            raise Exception(
+                "Expected to use {} hooks, but used {}".format(
+                    self._hook_count, hook_count
+                )
+            )
+
+    def _notify_change(self) -> None:
+        """
+        Notify the parent context that this context has changed.
+        Note that we're just re-rendering the whole tree on change.
+        TODO: We should be able to do better than this, and only re-render the parts that have actually changed.
+        """
+        logger.debug("Notifying parent context that child context has changed")
+        self._on_change()
+
+    def set_on_change(self, on_change: OnChangeCallable) -> None:
+        """
+        Set the on_change callback.
+        """
+        self._on_change = on_change
+
+    def has_state(self, key: StateKey) -> bool:
+        """
+        Check if the given key is in the state.
+        """
+        return key in self._state
+
+    def get_state(self, key: StateKey, default: Any = None) -> None:
+        """
+        Get the state for the given key.
+        """
+        if key not in self._state:
+            self._state[key] = default
+        return self._state[key]
+
+    def set_state(self, key: StateKey, value: Any) -> None:
+        """
+        Set the state for the given key.
+        """
+        # TODO: Should we throw here if it's called when we're in the middle of a render?
+        should_notify = False
+        if key in self._state:
+            # We only want to notify of a change when the value actually changes, not on the initial render
+            should_notify = True
+        self._state[key] = value
+        if should_notify:
+            self._notify_change()
+
+    def get_child_context(self, key: ContextKey) -> "RenderContext":
+        """
+        Get the child context for the given key.
+        """
+        logger.debug("Getting child context for key %s", key)
+        if key not in self._children_context:
+            logger.debug("Creating new child context for key %s", key)
+            child_context = RenderContext()
+            child_context.set_on_change(self._notify_change)
+            self._children_context[key] = child_context
+        return self._children_context[key]
+
+    def next_hook_index(self) -> int:
+        """
+        Increment the hook index.
+        """
+        self._hook_index += 1
+        return self._hook_index
diff --git a/plugins/ui/src/deephaven/ui/_internal/__init__.py b/plugins/ui/src/deephaven/ui/_internal/__init__.py
new file mode 100644
index 000000000..67a4aba5a
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/_internal/__init__.py
@@ -0,0 +1,9 @@
+from .RenderContext import RenderContext
+from .shared import get_context, set_context
+from .utils import (
+    get_component_name,
+    get_component_qualname,
+    to_camel_case,
+    dict_to_camel_case,
+    remove_empty_keys,
+)
diff --git a/plugins/ui/src/deephaven/ui/_internal/shared.py b/plugins/ui/src/deephaven/ui/_internal/shared.py
new file mode 100644
index 000000000..e2fa613e2
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/_internal/shared.py
@@ -0,0 +1,28 @@
+from .RenderContext import RenderContext
+from typing import Optional
+
+
+class UiSharedInternals:
+    """
+    Shared internal context for the deephaven.ui plugin to use when rendering.
+    Should be set at the start of a render call, and unset at the end.
+
+    TODO: Need to keep track of context for each given thread, in case we have multiple threads rendering at once.
+    """
+
+    _current_context: Optional[RenderContext] = None
+
+    @property
+    def current_context(self) -> RenderContext:
+        return self._current_context
+
+
+_deephaven_ui_shared_internals: UiSharedInternals = UiSharedInternals()
+
+
+def get_context() -> RenderContext:
+    return _deephaven_ui_shared_internals.current_context
+
+
+def set_context(context):
+    _deephaven_ui_shared_internals._current_context = context
diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py
new file mode 100644
index 000000000..e15bbd433
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/_internal/utils.py
@@ -0,0 +1,73 @@
+def get_component_name(component):
+    """
+    Get the name of the component
+
+    Args:
+        component: The component to get the name of.
+
+    Returns:
+        The name of the component.
+    """
+    try:
+        return component.__module__ + "." + component.__name__
+    except Exception as e:
+        return component.__class__.__module__ + "." + component.__class__.__name__
+
+
+def get_component_qualname(component):
+    """
+    Get the name of the component
+
+    Args:
+        component: The component to get the name of.
+
+    Returns:
+        The name of the component.
+    """
+    try:
+        return component.__module__ + "." + component.__qualname__
+    except Exception as e:
+        return component.__class__.__module__ + "." + component.__class__.__qualname__
+
+
+def to_camel_case(snake_case_text: str):
+    """
+    Convert a snake_case string to camelCase.
+
+    Args:
+        snake_case_text: The snake_case string to convert.
+
+    Returns:
+        The camelCase string.
+    """
+    components = snake_case_text.split("_")
+    return components[0] + "".join(x.title() for x in components[1:])
+
+
+def dict_to_camel_case(snake_case_dict: dict):
+    """
+    Convert a dict with snake_case keys to a dict with camelCase keys.
+
+    Args:
+        snake_case_dict: The snake_case dict to convert.
+
+    Returns:
+        The camelCase dict.
+    """
+    camel_case_dict = {}
+    for key, value in snake_case_dict.items():
+        camel_case_dict[to_camel_case(key)] = value
+    return camel_case_dict
+
+
+def remove_empty_keys(dict):
+    """
+    Remove keys from a dict that have a value of None.
+
+    Args:
+        dict: The dict to remove keys from.
+
+    Returns:
+        The dict with keys removed.
+    """
+    return {k: v for k, v in dict.items() if v is not None}
diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py
new file mode 100644
index 000000000..a38a5a84d
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/__init__.py
@@ -0,0 +1,26 @@
+from .icon import icon
+from .make_component import make_component as component
+from .spectrum import *
+
+
+__all__ = [
+    "action_button",
+    "checkbox",
+    "component",
+    "content",
+    "contextual_help",
+    "flex",
+    "grid",
+    "heading",
+    "icon",
+    "icon_wrapper",
+    "illustrated_message",
+    "html",
+    "slider",
+    "spectrum_element",
+    "switch",
+    "text",
+    "text_field",
+    "toggle_button",
+    "view",
+]
diff --git a/plugins/ui/src/deephaven/ui/components/html.py b/plugins/ui/src/deephaven/ui/components/html.py
new file mode 100644
index 000000000..24daf20fb
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/html.py
@@ -0,0 +1,154 @@
+"""
+Provides a set of functions for creating raw HTML elements.
+
+The components provided in deephaven.ui should be preferred over this module.
+"""
+from ..elements import BaseElement
+
+
+def html_element(tag, *children, **attributes):
+    """
+    Create a new HTML element. Render just returns the children that are passed in.
+
+    Args:
+        tag: The HTML tag for this element.
+        *children: The children of the element.
+        **attributes: Attributes to set on the element
+    """
+    return BaseElement(f"deephaven.ui.html.{tag}", *children, **attributes)
+
+
+def div(*children, **attributes):
+    return html_element("div", *children, **attributes)
+
+
+def span(*children, **attributes):
+    return html_element("span", *children, **attributes)
+
+
+def h1(*children, **attributes):
+    return html_element("h1", *children, **attributes)
+
+
+def h2(*children, **attributes):
+    return html_element("h2", *children, **attributes)
+
+
+def h3(*children, **attributes):
+    return html_element("h3", *children, **attributes)
+
+
+def h4(*children, **attributes):
+    return html_element("h4", *children, **attributes)
+
+
+def h5(*children, **attributes):
+    return html_element("h5", *children, **attributes)
+
+
+def h6(*children, **attributes):
+    return html_element("h6", *children, **attributes)
+
+
+def p(*children, **attributes):
+    return html_element("p", *children, **attributes)
+
+
+def a(*children, **attributes):
+    return html_element("a", *children, **attributes)
+
+
+def ul(*children, **attributes):
+    return html_element("ul", *children, **attributes)
+
+
+def ol(*children, **attributes):
+    return html_element("ol", *children, **attributes)
+
+
+def li(*children, **attributes):
+    return html_element("li", *children, **attributes)
+
+
+def table(*children, **attributes):
+    return html_element("table", *children, **attributes)
+
+
+def thead(*children, **attributes):
+    return html_element("thead", *children, **attributes)
+
+
+def tbody(*children, **attributes):
+    return html_element("tbody", *children, **attributes)
+
+
+def tr(*children, **attributes):
+    return html_element("tr", *children, **attributes)
+
+
+def th(*children, **attributes):
+    return html_element("th", *children, **attributes)
+
+
+def td(*children, **attributes):
+    return html_element("td", *children, **attributes)
+
+
+def b(*children, **attributes):
+    return html_element("b", *children, **attributes)
+
+
+def i(*children, **attributes):
+    return html_element("i", *children, **attributes)
+
+
+def br(*children, **attributes):
+    return html_element("br", *children, **attributes)
+
+
+def hr(*children, **attributes):
+    return html_element("hr", *children, **attributes)
+
+
+def pre(*children, **attributes):
+    return html_element("pre", *children, **attributes)
+
+
+def code(*children, **attributes):
+    return html_element("code", *children, **attributes)
+
+
+def img(*children, **attributes):
+    return html_element("img", *children, **attributes)
+
+
+def button(*children, **attributes):
+    return html_element("button", *children, **attributes)
+
+
+def input(*children, **attributes):
+    return html_element("input", *children, **attributes)
+
+
+def form(*children, **attributes):
+    return html_element("form", *children, **attributes)
+
+
+def label(*children, **attributes):
+    return html_element("label", *children, **attributes)
+
+
+def select(*children, **attributes):
+    return html_element("select", *children, **attributes)
+
+
+def option(*children, **attributes):
+    return html_element("option", *children, **attributes)
+
+
+def textarea(*children, **attributes):
+    return html_element("textarea", *children, **attributes)
+
+
+def style(*children, **attributes):
+    return html_element("style", *children, **attributes)
diff --git a/plugins/ui/src/deephaven/ui/components/icon.py b/plugins/ui/src/deephaven/ui/components/icon.py
new file mode 100644
index 000000000..babcff348
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/icon.py
@@ -0,0 +1,8 @@
+from ..elements import BaseElement
+
+
+def icon(name, *children, **props):
+    """
+    Get a Deephaven icon by name.
+    """
+    return BaseElement(f"deephaven.ui.icons.{name}", *children, **props)
diff --git a/plugins/ui/src/deephaven/ui/components/make_component.py b/plugins/ui/src/deephaven/ui/components/make_component.py
new file mode 100644
index 000000000..11f8fc8e3
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/make_component.py
@@ -0,0 +1,20 @@
+import functools
+import logging
+from .._internal import get_component_qualname
+from ..elements import FunctionElement
+
+logger = logging.getLogger(__name__)
+
+
+def make_component(func):
+    """
+    Create a ComponentNode from the passed in function.
+    """
+
+    @functools.wraps(func)
+    def make_component_node(*args, **kwargs):
+        component_type = get_component_qualname(func)
+
+        return FunctionElement(component_type, lambda: func(*args, **kwargs))
+
+    return make_component_node
diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py
new file mode 100644
index 000000000..6b22be14b
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py
@@ -0,0 +1,2 @@
+from .basic import *
+from .flex import *
diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/basic.py b/plugins/ui/src/deephaven/ui/components/spectrum/basic.py
new file mode 100644
index 000000000..8ab3ee956
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/spectrum/basic.py
@@ -0,0 +1,123 @@
+from ...elements import BaseElement
+
+
+def spectrum_element(name: str, *children, **props):
+    """
+    Base class for UI elements that are part of the Spectrum design system.
+    All names are automatically prefixed with "deephaven.ui.spectrum.", and all props are automatically camelCased.
+    """
+    return BaseElement(f"deephaven.ui.spectrum.{name}", *children, **props)
+
+
+def action_button(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum ActionButton component.
+    https://react-spectrum.adobe.com/react-spectrum/ActionButton.html
+    """
+    return spectrum_element("ActionButton", *children, **props)
+
+
+def checkbox(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Checkbox component.
+    https://react-spectrum.adobe.com/react-spectrum/Checkbox.html
+    """
+    return spectrum_element("Checkbox", *children, **props)
+
+
+def content(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Content component.
+    https://react-spectrum.adobe.com/react-spectrum/Content.html
+    """
+    return spectrum_element("Content", *children, **props)
+
+
+def contextual_help(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum ContextualHelp component.
+    https://react-spectrum.adobe.com/react-spectrum/ContextualHelp.html
+    """
+    return spectrum_element("ContextualHelp", *children, **props)
+
+
+def grid(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Grid component.
+    https://react-spectrum.adobe.com/react-spectrum/Grid.html
+    """
+    return spectrum_element("Grid", *children, **props)
+
+
+def heading(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Heading component.
+    https://react-spectrum.adobe.com/react-spectrum/Heading.html
+    """
+    return spectrum_element("Heading", *children, **props)
+
+
+def icon_wrapper(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Icon component.
+    Named icon_wrapper so as not to conflict with the Deephaven icon component.
+    TODO: This doesn't seem to work correctly. It throws an error saying `Cannot read properties of undefined (reading 'className')`.
+    https://react-spectrum.adobe.com/react-spectrum/Icon.html
+    """
+    return spectrum_element("Icon", *children, **props)
+
+
+def illustrated_message(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum IllustratedMessage component.
+    https://react-spectrum.adobe.com/react-spectrum/IllustratedMessage.html
+    """
+    return spectrum_element("IllustratedMessage", *children, **props)
+
+
+def slider(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Slider component.
+    https://react-spectrum.adobe.com/react-spectrum/Slider.html
+    """
+    return spectrum_element("Slider", *children, **props)
+
+
+def switch(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Switch component.
+    https://react-spectrum.adobe.com/react-spectrum/Switch.html
+    """
+    return spectrum_element("Switch", *children, **props)
+
+
+def text(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum Text component.
+    https://react-spectrum.adobe.com/react-spectrum/Text.html
+    """
+    return spectrum_element("Text", *children, **props)
+
+
+def text_field(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum TextField component.
+    https://react-spectrum.adobe.com/react-spectrum/TextField.html
+    """
+    return spectrum_element("TextField", *children, **props)
+
+
+def toggle_button(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum ToggleButton component.
+    https://react-spectrum.adobe.com/react-spectrum/ToggleButton.html
+    """
+    return spectrum_element("ToggleButton", *children, **props)
+
+
+def view(*children, **props):
+    """
+    Python implementation for the Adobe React Spectrum View component.
+    https://react-spectrum.adobe.com/react-spectrum/View.html
+    """
+    return spectrum_element("View", *children, **props)
diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/flex.py b/plugins/ui/src/deephaven/ui/components/spectrum/flex.py
new file mode 100644
index 000000000..ca17a7b23
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/components/spectrum/flex.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+from typing import Literal, Optional
+from .basic import spectrum_element
+
+
+def flex(
+    *children,
+    direction: Literal["row", "column", "row-reverse", "column-reverse"] = None,
+    wrap: Literal["wrap", "nowrap", "wrap-reverse"] = None,
+    justify_content: Literal[
+        "start",
+        "end",
+        "center",
+        "left",
+        "right",
+        "space-between",
+        "space-around",
+        "space-evenly",
+        "stretch",
+        "baseline",
+        "first baseline",
+        "last baseline",
+        "safe center",
+        "unsafe center",
+    ] = None,
+    align_content: Literal[
+        "start",
+        "end",
+        "center",
+        "space-between",
+        "space-around",
+        "space-evenly",
+        "stretch",
+        "baseline",
+        "first baseline",
+        "last baseline",
+        "safe center",
+        "unsafe center",
+    ] = None,
+    align_items: Optional[
+        Literal[
+            "start",
+            "end",
+            "center",
+            "stretch",
+            "self-start",
+            "self-end",
+            "baseline",
+            "first baseline",
+            "last baseline",
+            "safe center",
+            "unsafe center",
+        ]
+    ] = None,
+    gap: Optional[str | int | float] = None,
+    column_gap: Optional[str | int | float] = None,
+    row_gap: Optional[str | int | float] = None,
+    **props,
+):
+    """
+    Python implementation for the Adobe React Spectrum Flex component.
+    https://react-spectrum.adobe.com/react-spectrum/Flex.html
+    """
+    return spectrum_element(
+        "Flex",
+        *children,
+        direction=direction,
+        wrap=wrap,
+        justify_content=justify_content,
+        align_content=align_content,
+        align_items=align_items,
+        gap=gap,
+        column_gap=column_gap,
+        row_gap=row_gap,
+        **props,
+    )
diff --git a/plugins/ui/src/deephaven/ui/elements/BaseElement.py b/plugins/ui/src/deephaven/ui/elements/BaseElement.py
new file mode 100644
index 000000000..2857c0a46
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/elements/BaseElement.py
@@ -0,0 +1,29 @@
+from .Element import Element
+from .._internal import dict_to_camel_case, RenderContext
+
+
+class BaseElement(Element):
+    """
+    Base class for basic UI Elements that don't have any special rendering logic.
+    Must provide a name for the element.
+    """
+
+    def __init__(self, name: str, *children, **props):
+        self._name = name
+        if len(children) > 0 and props.get("children") is not None:
+            raise ValueError("Cannot provide both children and props.children")
+
+        if len(children) > 1:
+            props["children"] = list(children)
+        if len(children) == 1:
+            # If there's only one child, we pass it as a single child, not a list
+            # There are many React elements that expect only a single child, and will fail if they get a list (even if it only has one element)
+            props["children"] = children[0]
+        self._props = dict_to_camel_case(props)
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    def render(self, context: RenderContext):
+        return self._props
diff --git a/plugins/ui/src/deephaven/ui/elements/Element.py b/plugins/ui/src/deephaven/ui/elements/Element.py
new file mode 100644
index 000000000..ce766a1dd
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/elements/Element.py
@@ -0,0 +1,29 @@
+from abc import ABC, abstractmethod
+from .._internal import RenderContext
+
+
+class Element(ABC):
+    """
+    Interface for all custom UI elements that have children.
+    """
+
+    @property
+    def name(self) -> str:
+        """
+        Get the name of this element. Custom subclasses that want to be rendered differently on the client should override this a provide their own unique name.
+
+        Returns:
+            The unique name of this element.
+        """
+        return "deephaven.ui.Element"
+
+    @abstractmethod
+    def render(self, context: RenderContext) -> dict:
+        """
+        Renders this element, and returns the result as a dictionary of props for the element.
+        If you just want to render children, pass back a dict with children only, e.g. { "children": ... }
+
+        Returns:
+            The props of this element.
+        """
+        pass
diff --git a/plugins/ui/src/deephaven/ui/elements/FunctionElement.py b/plugins/ui/src/deephaven/ui/elements/FunctionElement.py
new file mode 100644
index 000000000..4bf03de48
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/elements/FunctionElement.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+import logging
+from typing import Callable
+from .Element import Element
+from .._internal import RenderContext, get_context, set_context
+
+logger = logging.getLogger(__name__)
+
+
+class FunctionElement(Element):
+    def __init__(self, name: str, render: Callable[[], list[Element]]):
+        """
+        Create an element that takes a function to render.
+
+        Args:
+            name: Name of the component. Typically, the module joined with the name of the function.
+            render: The render function to call when the component needs to be rendered.
+        """
+        self._name = name
+        self._render = render
+
+    @property
+    def name(self):
+        return self._name
+
+    def render(self, context: RenderContext) -> list[Element]:
+        """
+        Render the component. Should only be called when actually rendering the component, e.g. exporting it to the client.
+
+        Args:
+            context: Context to render the component in
+
+        Returns:
+            The rendered component.
+        """
+        old_context = get_context()
+        logger.debug("old context is %s and new context is %s", old_context, context)
+
+        set_context(context)
+        with context:
+            children = self._render()
+
+        logger.debug("Resetting to old context %s", old_context)
+        set_context(old_context)
+
+        return {"children": children}
diff --git a/plugins/ui/src/deephaven/ui/elements/__init__.py b/plugins/ui/src/deephaven/ui/elements/__init__.py
new file mode 100644
index 000000000..712b68b7e
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/elements/__init__.py
@@ -0,0 +1,9 @@
+from .Element import Element
+from .BaseElement import BaseElement
+from .FunctionElement import FunctionElement
+
+__all__ = [
+    "BaseElement",
+    "Element",
+    "FunctionElement",
+]
diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py
new file mode 100644
index 000000000..3ed0f15c3
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py
@@ -0,0 +1,13 @@
+from .use_callback import use_callback
+from .use_effect import use_effect
+from .use_memo import use_memo
+from .use_state import use_state
+from .use_ref import use_ref
+
+__all__ = [
+    "use_callback",
+    "use_effect",
+    "use_memo",
+    "use_state",
+    "use_ref",
+]
diff --git a/plugins/ui/src/deephaven/ui/hooks/use_callback.py b/plugins/ui/src/deephaven/ui/hooks/use_callback.py
new file mode 100644
index 000000000..653a7bc69
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/use_callback.py
@@ -0,0 +1,25 @@
+from .use_ref import use_ref
+
+
+def use_callback(func, dependencies):
+    """
+    Create a stable handle for a callback function. The callback will only be recreated if the dependencies change.
+
+    Args:
+        func: The function to create a stable handle to.
+        dependencies: The dependencies to check for changes.
+
+    Returns:
+        The stable handle to the callback function.
+    """
+    deps_ref = use_ref(None)
+    callback_ref = use_ref(lambda: None)
+    stable_callback_ref = use_ref(
+        lambda *args, **kwargs: callback_ref.current(*args, **kwargs)
+    )
+
+    if deps_ref.current != dependencies:
+        callback_ref.current = func
+        deps_ref.current = dependencies
+
+    return stable_callback_ref.current
diff --git a/plugins/ui/src/deephaven/ui/hooks/use_effect.py b/plugins/ui/src/deephaven/ui/hooks/use_effect.py
new file mode 100644
index 000000000..5ba27d6b7
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/use_effect.py
@@ -0,0 +1,28 @@
+from .use_ref import use_ref
+
+
+def use_effect(func, dependencies):
+    """
+    Call a function when the dependencies change. Optionally return a cleanup function to be called when dependencies change again or component is unmounted.
+
+    Args:
+        func: The function to call when the dependencies change.
+        dependencies: The dependencies to check for changes.
+
+    Returns:
+        None
+    """
+    deps_ref = use_ref(None)
+    cleanup_ref = use_ref(lambda: None)
+
+    # Check if the dependencies have changed
+    if deps_ref.current != dependencies:
+        if cleanup_ref.current is not None:
+            # Call the cleanup function from the previous effect
+            cleanup_ref.current()
+
+        # Dependencies have changed, so call the effect function and store the new cleanup that's returned
+        cleanup_ref.current = func()
+
+        # Update the dependencies
+        deps_ref.current = dependencies
diff --git a/plugins/ui/src/deephaven/ui/hooks/use_memo.py b/plugins/ui/src/deephaven/ui/hooks/use_memo.py
new file mode 100644
index 000000000..ab437b60e
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/use_memo.py
@@ -0,0 +1,22 @@
+from .use_ref import use_ref
+
+
+def use_memo(func, dependencies):
+    """
+    Memoize the result of a function call. The function will only be called again if the dependencies change.
+
+    Args:
+        func: The function to memoize.
+        dependencies: The dependencies to check for changes.
+
+    Returns:
+        The memoized result of the function call.
+    """
+    deps_ref = use_ref(None)
+    value_ref = use_ref(None)
+
+    if deps_ref.current != dependencies:
+        value_ref.current = func()
+        deps_ref.current = dependencies
+
+    return value_ref.current
diff --git a/plugins/ui/src/deephaven/ui/hooks/use_ref.py b/plugins/ui/src/deephaven/ui/hooks/use_ref.py
new file mode 100644
index 000000000..cdf9281d9
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/use_ref.py
@@ -0,0 +1,35 @@
+from .use_state import use_state
+from typing import Generic, overload, TypeVar
+
+T = TypeVar("T")
+
+
+class Ref(Generic[T]):
+    """
+    A simple object that just stores a reference to a value in `current`
+    Use it with a `use_ref` hook.
+    """
+
+    current: T
+
+    def __init__(self, current: T):
+        self.current = current
+
+
+@overload
+def use_ref(initial_value: T) -> Ref[T]:
+    ...
+
+
+def use_ref(initial_value: T | None = None) -> Ref[T | None]:
+    """
+    Store a reference to a value that will persist across renders.
+
+    Args:
+      initial_value: The initial value of the reference.
+
+    Returns:
+      A Ref object with a current property that you can get/set
+    """
+    ref, _ = use_state(Ref(initial_value))
+    return ref
diff --git a/plugins/ui/src/deephaven/ui/hooks/use_state.py b/plugins/ui/src/deephaven/ui/hooks/use_state.py
new file mode 100644
index 000000000..5b4d3df64
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/hooks/use_state.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+import logging
+from typing import Callable, TypeVar, overload
+from .._internal.shared import get_context
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T")
+
+
+@overload
+def use_state(initial_value: T) -> (T, Callable[[T], None]):
+    ...
+
+
+def use_state(initial_value: T | None = None) -> (T | None, Callable[[T], None]):
+    context = get_context()
+    hook_index = context.next_hook_index()
+
+    value = initial_value
+    if context.has_state(hook_index):
+        value = context.get_state(hook_index)
+    else:
+        # Initialize the state
+        if callable(value):
+            value = value()
+        context.set_state(hook_index, value)
+
+    def set_value(new_value):
+        # Set the value in the context state and trigger a re-render
+        logger.debug("use_state set_value called with %s", new_value)
+        context.set_state(hook_index, new_value)
+
+    return value, set_value
diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py
new file mode 100644
index 000000000..6448f9f43
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+import json
+import io
+from jsonrpc import JSONRPCResponseManager, Dispatcher
+import logging
+from typing import Any
+from deephaven.plugin.object_type import MessageStream
+from ..elements import Element
+from ..renderer import NodeEncoder, Renderer, RenderedNode
+from .._internal import RenderContext
+from ..renderer.NodeEncoder import NodeEncoder
+
+logger = logging.getLogger(__name__)
+
+
+class ElementMessageStream(MessageStream):
+    def __init__(self, element: Element, connection: MessageStream):
+        """
+        Create a new ElementMessageStream. Renders the element in a render context, and sends the rendered result to the
+        client. Automatically re-renders the element when the element changes and sends updates to the client as well.
+
+        Args:
+            element: The element to render
+            connection: The connection to send the rendered element to
+        """
+        self._element = element
+        self._connection = connection
+        self._update_count = 0
+        self._message_id = 0
+        self._manager = JSONRPCResponseManager()
+        self._dispatcher = Dispatcher()
+
+    def start(self) -> None:
+        context = RenderContext()
+        renderer = Renderer(context)
+
+        def update():
+            logger.debug("ElementMessageStream update")
+            node = renderer.render(self._element)
+            self._send_document_update(node)
+
+        context.set_on_change(update)
+        update()
+
+    def on_close(self) -> None:
+        pass
+
+    def on_data(self, payload: bytes, references: list[Any]) -> None:
+        decoded_payload = io.BytesIO(payload).read().decode()
+        logger.debug("Payload received: %s", decoded_payload)
+
+        response = self._manager.handle(decoded_payload, self._dispatcher)
+
+        if response is None:
+            return
+
+        payload = response.json
+        logger.debug("Response: %s, %s", type(payload), payload)
+        self._connection.on_data(payload.encode(), [])
+
+    def _get_next_message_id(self) -> int:
+        self._message_id += 1
+        return self._message_id
+
+    def _make_notification(self, method: str, *params: list[Any]) -> None:
+        """
+        Make a JSON-RPC notification. Can notify the client without expecting a response.
+
+        Args:
+            method: The method to call
+            params: The parameters to pass to the method
+        """
+        return {
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+        }
+
+    def _make_request(self, method: str, *params: list[Any]) -> None:
+        """
+        Make a JSON-RPC request. Messages the client and expects a response.
+
+        Args:
+            method: The method to call
+            params: The parameters to pass to the method
+        """
+        return {
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+            "id": self._get_next_message_id(),
+        }
+
+    def _send_document_update(self, root: RenderedNode) -> None:
+        """
+        Send a document update to the client. Currently just sends the entire document for each update.
+
+        Args:
+            root: The root node of the document to send
+        """
+        # We use an ID prefix to ensure that the callable ids are unique across each document render/update
+        # That way we don't have to worry about callables from previous renders being called accidentally
+        self._update_count += 1
+        id_prefix = f"cb_{self._update_count}_"
+
+        # TODO(#67): Send a diff of the document instead of the entire document.
+        request = self._make_notification("documentUpdated", root)
+        encoder = NodeEncoder(callable_id_prefix=id_prefix, separators=(",", ":"))
+        payload = encoder.encode(request)
+
+        logger.debug(f"Sending payload: {payload}")
+
+        dispatcher = Dispatcher()
+        for i, callable in enumerate(encoder.callables):
+            key = f"{id_prefix}{i}"
+            logger.debug("Registering callable %s", key)
+            dispatcher[key] = callable
+        self._dispatcher = dispatcher
+        self._connection.on_data(payload.encode(), encoder.objects)
diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementType.py b/plugins/ui/src/deephaven/ui/object_types/ElementType.py
new file mode 100644
index 000000000..065b0b0cc
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/object_types/ElementType.py
@@ -0,0 +1,22 @@
+from deephaven.plugin.object_type import BidirectionalObjectType, MessageStream
+from ..elements import Element
+from .._internal import get_component_name
+from .ElementMessageStream import ElementMessageStream
+
+
+class ElementType(BidirectionalObjectType):
+    """
+    Defines the Element type for the Deephaven plugin system.
+    """
+
+    @property
+    def name(self) -> str:
+        return "deephaven.ui.Element"
+
+    def is_type(self, obj: any) -> bool:
+        return isinstance(obj, Element)
+
+    def create_client_connection(self, obj: Element, connection: MessageStream):
+        client_connection = ElementMessageStream(obj, connection)
+        client_connection.start()
+        return client_connection
diff --git a/plugins/ui/src/deephaven/ui/object_types/__init__.py b/plugins/ui/src/deephaven/ui/object_types/__init__.py
new file mode 100644
index 000000000..accae10cb
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/object_types/__init__.py
@@ -0,0 +1,2 @@
+from .ElementMessageStream import ElementMessageStream
+from .ElementType import ElementType
diff --git a/plugins/ui/src/deephaven/ui/renderer/NodeEncoder.py b/plugins/ui/src/deephaven/ui/renderer/NodeEncoder.py
new file mode 100644
index 000000000..e6b56946d
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/renderer/NodeEncoder.py
@@ -0,0 +1,106 @@
+from collections.abc import Iterator
+import json
+from typing import Any, Callable
+from .RenderedNode import RenderedNode
+
+CALLABLE_KEY = "__dh_cbid"
+OBJECT_KEY = "__dh_obid"
+ELEMENT_KEY = "__dh_elem"
+
+
+class NodeEncoder(json.JSONEncoder):
+    """
+    Encode the node in JSON. Store any replaced objects and callables in their respective arrays.
+    - RenderedNodes in the tree are replaced with a dict with property `ELEMENT_KEY` set to the name of the element, and props set to the props key.
+    - callables in the tree are replaced with an object with property `CALLABLE_KEY` set to the index in the callables array.
+    - non-serializable objects in the tree are replaced wtih an object with property `OBJECT_KEY` set to the index in the objects array.
+    """
+
+    _callable_id_prefix: str
+    """
+    Prefix to use for callable ids. Used to ensure callables used in stream are unique.
+    """
+
+    _callables: list[Callable]
+    """
+    List of callables parsed out of the document
+    """
+
+    _callable_id_dict: dict[int, int]
+    """
+    Dictionary from a callables id to the index in the callables array.
+    """
+
+    _objects: list[Any]
+    """
+    List of objects parsed out of the document
+    """
+
+    _object_id_dict: dict[int, int]
+    """
+    Dictionary from an objects id to the index in the objects array.
+    """
+
+    def __init__(self, *args, callable_id_prefix: str = "cb", **kwargs):
+        """
+        Create a new NodeEncoder.
+
+        Args:
+            *args: Arguments to pass to the JSONEncoder constructor
+            callable_id_prefix: Prefix to use for callable ids. Used to ensure callables used in stream are unique.
+            **kwargs: Args to pass to the JSONEncoder constructor
+        """
+        super().__init__(*args, **kwargs)
+        self._callable_id_prefix = callable_id_prefix
+        self._callables = []
+        self._callable_id_dict = {}
+        self._objects = []
+        self._object_id_dict = {}
+
+    def default(self, node: Any):
+        if isinstance(node, RenderedNode):
+            return self._convert_rendered_node(node)
+        elif callable(node):
+            return self._convert_callable(node)
+        else:
+            try:
+                return super().default(node)
+            except TypeError:
+                # This is a non-serializable object. We'll store a reference to the object in the objects array.
+                return self._convert_object(node)
+
+    @property
+    def callables(self) -> list[Callable]:
+        return self._callables
+
+    @property
+    def objects(self) -> list[Any]:
+        return self._objects
+
+    def _convert_rendered_node(self, node: RenderedNode):
+        result = {ELEMENT_KEY: node.name}
+        if node.props is not None:
+            result["props"] = node.props
+        return result
+
+    def _convert_callable(self, cb: callable):
+        callable_id = id(cb)
+        callable_index = self._callable_id_dict.get(callable_id, len(self._callables))
+        if callable_index == len(self._callables):
+            self._callables.append(cb)
+            self._callable_id_dict[callable_id] = callable_index
+
+        return {
+            CALLABLE_KEY: f"{self._callable_id_prefix}{callable_index}",
+        }
+
+    def _convert_object(self, obj: Any):
+        object_id = id(obj)
+        object_index = self._object_id_dict.get(object_id, len(self._objects))
+        if object_index == len(self._objects):
+            self._objects.append(obj)
+            self._object_id_dict[object_id] = object_index
+
+        return {
+            OBJECT_KEY: object_index,
+        }
diff --git a/plugins/ui/src/deephaven/ui/renderer/RenderedNode.py b/plugins/ui/src/deephaven/ui/renderer/RenderedNode.py
new file mode 100644
index 000000000..775f5caa9
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/renderer/RenderedNode.py
@@ -0,0 +1,32 @@
+class RenderedNode:
+    """
+    Represents the result of rendering a node.
+    """
+
+    _name: str
+    _props: dict | None
+
+    def __init__(self, name: str, props: dict = None):
+        """
+        Stores the result of a rendered node
+
+        Args:
+            name: The name of the node.
+            props: The props of the node.
+        """
+        self._name = name
+        self._props = props
+
+    @property
+    def name(self) -> str:
+        """
+        Get the name of the node.
+        """
+        return self._name
+
+    @property
+    def props(self) -> dict | None:
+        """
+        Get the props of the node.
+        """
+        return self._props
diff --git a/plugins/ui/src/deephaven/ui/renderer/Renderer.py b/plugins/ui/src/deephaven/ui/renderer/Renderer.py
new file mode 100644
index 000000000..c1cb1d750
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py
@@ -0,0 +1,62 @@
+import logging
+from typing import Any
+from .._internal import RenderContext
+from ..elements import Element
+from .RenderedNode import RenderedNode
+
+logger = logging.getLogger(__name__)
+
+
+def _render(context: RenderContext, element: Element):
+    """
+    Render an Element.
+
+    Args:
+        context: The context to render the component in.
+        element: The element to render.
+
+    Returns:
+        The RenderedNode representing the element.
+    """
+
+    def render_child(child: Any, child_context: RenderContext):
+        logger.debug("child_context is %s", child_context)
+        if isinstance(child, list) or isinstance(child, tuple):
+            logger.debug("render_child list: %s", child)
+            return [
+                render_child(child, child_context.get_child_context(i))
+                for i, child in enumerate(child)
+            ]
+        if isinstance(child, dict):
+            logger.debug("render_child dict: %s", child)
+            return {
+                key: render_child(value, child_context.get_child_context(key))
+                for key, value in child.items()
+            }
+        if isinstance(child, Element):
+            logger.debug(
+                "render_child element %s: %s",
+                type(child),
+                child,
+            )
+            return _render(child_context, child)
+        else:
+            logger.debug("render_child returning child (%s): %s", type(child), child)
+            return child
+
+    logger.debug("Rendering %s: ", element.name)
+
+    props = element.render(context)
+
+    # We also need to render any elements that are passed in as props
+    props = render_child(props, context)
+
+    return RenderedNode(element.name, props)
+
+
+class Renderer:
+    def __init__(self, context: RenderContext = RenderContext()):
+        self._context = context
+
+    def render(self, element: Element):
+        return _render(self._context, element)
diff --git a/plugins/ui/src/deephaven/ui/renderer/__init__.py b/plugins/ui/src/deephaven/ui/renderer/__init__.py
new file mode 100644
index 000000000..08be7f69b
--- /dev/null
+++ b/plugins/ui/src/deephaven/ui/renderer/__init__.py
@@ -0,0 +1,3 @@
+from .NodeEncoder import NodeEncoder
+from .Renderer import Renderer
+from .RenderedNode import RenderedNode
diff --git a/plugins/ui/src/js/.gitignore b/plugins/ui/src/js/.gitignore
new file mode 100644
index 000000000..d22612a8d
--- /dev/null
+++ b/plugins/ui/src/js/.gitignore
@@ -0,0 +1,5 @@
+# Ignore npm dependencies
+/node_modules
+
+# Ignore output directory
+/dist
diff --git a/plugins/ui/src/js/LICENSE b/plugins/ui/src/js/LICENSE
new file mode 100644
index 000000000..8318dc07b
--- /dev/null
+++ b/plugins/ui/src/js/LICENSE
@@ -0,0 +1,176 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1.  Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+2.  Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+3.  Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+4.  Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+    Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+    stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+    that You distribute, all copyright, patent, trademark, and
+    attribution notices from the Source form of the Work,
+    excluding those notices that do not pertain to any part of
+    the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+    distribution, then any Derivative Works that You distribute must
+    include a readable copy of the attribution notices contained
+    within such NOTICE file, excluding those notices that do not
+    pertain to any part of the Derivative Works, in at least one
+    of the following places: within a NOTICE text file distributed
+    as part of the Derivative Works; within the Source form or
+    documentation, if provided along with the Derivative Works; or,
+    within a display generated by the Derivative Works, if and
+    wherever such third-party notices normally appear. The contents
+    of the NOTICE file are for informational purposes only and
+    do not modify the License. You may add Your own attribution
+    notices within Derivative Works that You distribute, alongside
+    or as an addendum to the NOTICE text from the Work, provided
+    that such additional attribution notices cannot be construed
+    as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+5.  Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+6.  Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+7.  Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+8.  Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+9.  Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json
new file mode 100644
index 000000000..40644a151
--- /dev/null
+++ b/plugins/ui/src/js/package.json
@@ -0,0 +1,65 @@
+{
+  "name": "@deephaven/js-plugin-ui",
+  "version": "0.0.1",
+  "description": "Deephaven UI plugin",
+  "keywords": [
+    "Deephaven",
+    "plugin",
+    "deephaven-plugin",
+    "deephaven-js-plugin",
+    "ui",
+    "layout",
+    "programmatic layouts",
+    "callbacks"
+  ],
+  "author": "Deephaven Data Labs LLC",
+  "license": "Apache-2.0",
+  "main": "dist/index.js",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deephaven/deephaven-plugins"
+  },
+  "bugs": {
+    "url": "https://github.com/deephaven/deephaven-plugins/issues"
+  },
+  "homepage": "https://github.com/deephaven/deephaven-plugins/tree/main/plugins/ui",
+  "scripts": {
+    "start": "vite build --watch",
+    "build": "vite build"
+  },
+  "devDependencies": {
+    "@deephaven/jsapi-types": "^0.49.0",
+    "@types/react": "^17.0.2",
+    "@vitejs/plugin-react-swc": "^3.0.0",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "typescript": "^4.5.4",
+    "vite": "~4.1.4"
+  },
+  "peerDependencies": {
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2"
+  },
+  "dependencies": {
+    "@adobe/react-spectrum": "^3.29.0",
+    "@deephaven/chart": "^0.49.0",
+    "@deephaven/components": "^0.49.0",
+    "@deephaven/dashboard": "^0.49.0",
+    "@deephaven/dashboard-core-plugins": "^0.49.0",
+    "@deephaven/icons": "^0.49.0",
+    "@deephaven/iris-grid": "^0.49.0",
+    "@deephaven/jsapi-bootstrap": "^0.49.0",
+    "@deephaven/log": "^0.49.0",
+    "@deephaven/react-hooks": "^0.49.0",
+    "@deephaven/utils": "^0.49.0",
+    "@fortawesome/react-fontawesome": "^0.2.0",
+    "shortid": "^2.2.16",
+    "json-rpc-2.0": "^1.6.0"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "files": [
+    "dist"
+  ]
+}
diff --git a/plugins/ui/src/js/src/DashboardPlugin.tsx b/plugins/ui/src/js/src/DashboardPlugin.tsx
new file mode 100644
index 000000000..302c16deb
--- /dev/null
+++ b/plugins/ui/src/js/src/DashboardPlugin.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import {
+  DashboardPluginComponentProps,
+  useDashboardPanel,
+} from '@deephaven/dashboard';
+import ElementPanel from './ElementPanel';
+import styles from './styles.scss?inline';
+
+const NAME_ELEMENT = 'deephaven.ui.Element';
+
+export function DashboardPlugin(
+  props: DashboardPluginComponentProps
+): JSX.Element | null {
+  useDashboardPanel({
+    dashboardProps: props,
+    component: ElementPanel,
+    componentName: ElementPanel.displayName,
+    supportedTypes: [NAME_ELEMENT],
+  });
+
+  return <style>{styles}</style>;
+}
+
+export default DashboardPlugin;
diff --git a/plugins/ui/src/js/src/ElementPanel.tsx b/plugins/ui/src/js/src/ElementPanel.tsx
new file mode 100644
index 000000000..539e91a8f
--- /dev/null
+++ b/plugins/ui/src/js/src/ElementPanel.tsx
@@ -0,0 +1,187 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+  JSONRPCClient,
+  JSONRPCServer,
+  JSONRPCServerAndClient,
+} from 'json-rpc-2.0';
+import { type ChartPanelProps } from '@deephaven/dashboard-core-plugins';
+import { useApi } from '@deephaven/jsapi-bootstrap';
+import Log from '@deephaven/log';
+import {
+  CALLABLE_KEY,
+  ExportedObject,
+  OBJECT_KEY,
+  isCallableNode,
+  isElementNode,
+  isObjectNode,
+} from './ElementUtils';
+import ElementView from './ElementView';
+import ObjectView from './ObjectView';
+
+const log = Log.module('@deephaven/js-plugin-ui/ElementPanel');
+
+export interface WidgetMessageDetails {
+  getDataAsBase64(): string;
+  getDataAsString(): string;
+  exportedObjects: ExportedObject[];
+}
+
+export interface WidgetMessageEvent {
+  detail: WidgetMessageDetails;
+}
+
+export interface JsWidget extends WidgetMessageDetails {
+  addEventListener: (
+    type: string,
+    listener: (event: WidgetMessageEvent) => void
+  ) => () => void;
+  sendMessage: (message: string, args: unknown[]) => void;
+}
+
+export interface ElementPanelProps extends ChartPanelProps {
+  fetch(): Promise<JsWidget>;
+}
+
+function ElementPanel(props: ElementPanelProps) {
+  const { fetch } = props;
+  const dh = useApi();
+
+  const [widget, setWidget] = useState<JsWidget>();
+  const [element, setElement] = useState<React.ReactNode>();
+
+  // Bi-directional communication as defined in https://www.npmjs.com/package/json-rpc-2.0
+  const jsonClient = useMemo(
+    () =>
+      widget != null
+        ? new JSONRPCServerAndClient(
+            new JSONRPCServer(),
+            new JSONRPCClient(request => {
+              log.debug('Sending request', request);
+              widget.sendMessage(JSON.stringify(request), []);
+            })
+          )
+        : null,
+    [widget]
+  );
+
+  /**
+   * Parse the data from the server, replacing any callable nodes with functions that call the server.
+   */
+  const parseData = useCallback(
+    (data: string, exportedObjects: ExportedObject[]) =>
+      JSON.parse(data, (key, value) => {
+        // Need to re-hydrate any objects that are defined
+        if (isCallableNode(value)) {
+          const callableId = value[CALLABLE_KEY];
+          log.debug2('Registering callableId', callableId);
+          return async (...args: unknown[]) => {
+            log.debug('Callable called', callableId, ...args);
+            return jsonClient?.request(callableId, args);
+          };
+        }
+        if (isObjectNode(value)) {
+          // Replace this node with the exported object
+          const exportedObject = exportedObjects[value[OBJECT_KEY]];
+
+          // TODO: Only export the object view if it's being rendered as a child or is the root...
+          // Should probably just return it as just the object here, then parse the tree after looking for all exported objects rendered as nodes...
+          // Or get ElementView to handle it instead...
+          return <ObjectView object={exportedObject} />;
+        }
+        if (isElementNode(value)) {
+          return <ElementView element={value} />;
+        }
+
+        return value;
+      }),
+    [jsonClient]
+  );
+
+  useEffect(
+    function initMethods() {
+      if (jsonClient == null) {
+        return;
+      }
+
+      log.debug('Adding methods to jsonClient');
+      jsonClient.addMethod(
+        'documentUpdated',
+        async (newDocument: React.ReactNode) => {
+          log.debug('documentUpdated', newDocument);
+          setElement(newDocument);
+        }
+      );
+
+      return () => {
+        jsonClient.rejectAllPendingRequests('Widget was changed');
+      };
+    },
+    [jsonClient]
+  );
+
+  useEffect(() => {
+    if (widget == null) {
+      return;
+    }
+    function receiveData(data: string, exportedObjects: ExportedObject[]) {
+      log.debug('Data received', data, exportedObjects);
+      const parsedData = parseData(data, exportedObjects);
+      jsonClient?.receiveAndSend(parsedData);
+    }
+
+    const cleanup = widget.addEventListener(
+      dh.Widget.EVENT_MESSAGE,
+      async (event: WidgetMessageEvent) => {
+        receiveData(
+          event.detail.getDataAsString(),
+          event.detail.exportedObjects
+        );
+      }
+    );
+
+    log.info('Receiving initial data');
+    // We need to get the initial data and process it. It should be a documentUpdated command.
+    receiveData(widget.getDataAsString(), widget.exportedObjects);
+
+    return () => {
+      log.info('Cleaning up listener');
+      cleanup();
+    };
+  }, [dh, jsonClient, parseData, widget]);
+
+  useEffect(
+    function loadWidget() {
+      let isCancelled = false;
+      async function loadWidgetInternal() {
+        const newWidget = await fetch();
+        if (isCancelled) {
+          return;
+        }
+        log.debug('newWidget', newWidget);
+        setWidget(newWidget);
+      }
+      loadWidgetInternal();
+      return () => {
+        isCancelled = true;
+      };
+    },
+    [fetch]
+  );
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        flexDirection: 'column',
+        height: '100%',
+        width: '100%',
+      }}
+    >
+      {element}
+    </div>
+  );
+}
+
+ElementPanel.displayName = '@deephaven/js-plugin-ui/ElementPanel';
+
+export default ElementPanel;
diff --git a/plugins/ui/src/js/src/ElementUtils.ts b/plugins/ui/src/js/src/ElementUtils.ts
new file mode 100644
index 000000000..b2aa53693
--- /dev/null
+++ b/plugins/ui/src/js/src/ElementUtils.ts
@@ -0,0 +1,44 @@
+export const CALLABLE_KEY = '__dh_cbid';
+export const OBJECT_KEY = '__dh_obid';
+export const ELEMENT_KEY = '__dh_elem';
+
+export type CallableNode = {
+  /** The name of the callable to call */
+  [CALLABLE_KEY]: string;
+};
+
+export type ObjectNode = {
+  /** The index of the object in the exported objects array */
+  [OBJECT_KEY]: number;
+};
+
+export type ElementNode = {
+  [ELEMENT_KEY]: string;
+  props?: { [key: string]: unknown };
+};
+
+export function isObjectNode(obj: unknown): obj is ObjectNode {
+  return obj != null && typeof obj === 'object' && OBJECT_KEY in obj;
+}
+
+export function isElementNode(obj: unknown): obj is ElementNode {
+  return obj != null && typeof obj === 'object' && ELEMENT_KEY in obj;
+}
+
+export function isCallableNode(obj: unknown): obj is CallableNode {
+  return obj != null && typeof obj === 'object' && CALLABLE_KEY in obj;
+}
+
+export function isExportedObject(obj: unknown): obj is ExportedObject {
+  return (
+    obj != null &&
+    typeof obj === 'object' &&
+    typeof (obj as ExportedObject).fetch === 'function' &&
+    typeof (obj as ExportedObject).type === 'string'
+  );
+}
+
+export type ExportedObject<T = unknown> = {
+  fetch(): Promise<T>;
+  type: string;
+};
diff --git a/plugins/ui/src/js/src/ElementView.tsx b/plugins/ui/src/js/src/ElementView.tsx
new file mode 100644
index 000000000..d16a4c706
--- /dev/null
+++ b/plugins/ui/src/js/src/ElementView.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { ElementNode } from './ElementUtils';
+import { isHTMLElementNode } from './HTMLElementUtils';
+import HTMLElementView from './HTMLElementView';
+import { isSpectrumElementNode } from './SpectrumElementUtils';
+import SpectrumElementView from './SpectrumElementView';
+import { isIconElementNode } from './IconElementUtils';
+import IconElementView from './IconElementView';
+
+export type ElementViewProps = {
+  element: ElementNode;
+};
+
+export function ElementView({ element }: ElementViewProps): JSX.Element | null {
+  if (isHTMLElementNode(element)) {
+    return <HTMLElementView element={element} />;
+  }
+  if (isSpectrumElementNode(element)) {
+    return <SpectrumElementView element={element} />;
+  }
+  if (isIconElementNode(element)) {
+    return <IconElementView element={element} />;
+  }
+
+  // No special rendering for this node, just render the children
+  const { props } = element;
+  // eslint-disable-next-line react/jsx-no-useless-fragment, react/prop-types
+  return <>{props?.children ?? null}</>;
+}
+
+export default ElementView;
diff --git a/plugins/ui/src/js/src/FigureObject.tsx b/plugins/ui/src/js/src/FigureObject.tsx
new file mode 100644
index 000000000..091db5fa0
--- /dev/null
+++ b/plugins/ui/src/js/src/FigureObject.tsx
@@ -0,0 +1,44 @@
+import React, { useEffect, useState } from 'react';
+import { Chart, ChartModel, ChartModelFactory } from '@deephaven/chart';
+import { useApi } from '@deephaven/jsapi-bootstrap';
+import type { Figure } from '@deephaven/jsapi-types';
+import shortid from 'shortid';
+
+export interface FigureObjectProps {
+  object: Figure;
+}
+
+function FigureObject(props: FigureObjectProps) {
+  const { object } = props;
+  const dh = useApi();
+  const [model, setModel] = useState<ChartModel>();
+  const [key, setKey] = useState(shortid());
+
+  useEffect(() => {
+    async function loadModel() {
+      const newModel = await ChartModelFactory.makeModel(dh, undefined, object);
+      setModel(newModel);
+
+      // TODO: Chart.tsx doesn't handle the case where the model has been updated. Update the key so we get a new chart every time the model updates.
+      setKey(shortid());
+    }
+    loadModel();
+  }, [dh, object]);
+
+  return (
+    <div
+      className="ui-figure-object"
+      style={{
+        position: 'relative',
+        flexGrow: 1,
+        flexShrink: 1,
+      }}
+    >
+      {model && <Chart model={model} key={key} />}
+    </div>
+  );
+}
+
+FigureObject.displayName = 'FigureObject';
+
+export default FigureObject;
diff --git a/plugins/ui/src/js/src/HTMLElementUtils.ts b/plugins/ui/src/js/src/HTMLElementUtils.ts
new file mode 100644
index 000000000..14530ea77
--- /dev/null
+++ b/plugins/ui/src/js/src/HTMLElementUtils.ts
@@ -0,0 +1,28 @@
+import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils';
+
+export const HTML_ELEMENT_TYPE_PREFIX = 'deephaven.ui.html.';
+
+export type HTMLElementName =
+  `${typeof HTML_ELEMENT_TYPE_PREFIX}${keyof JSX.IntrinsicElements}`;
+
+export type HTMLElementNode = ElementNode & {
+  [ELEMENT_KEY]: HTMLElementName;
+};
+
+export function isHTMLElementNode(obj: unknown): obj is HTMLElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as HTMLElementNode)[ELEMENT_KEY].startsWith(HTML_ELEMENT_TYPE_PREFIX)
+  );
+}
+
+/**
+ * Get the HTML tag name for the element
+ * @param name Name of the element
+ * @returns The HTML tag name for the element
+ */
+export function getHTMLTag(name: HTMLElementName): keyof JSX.IntrinsicElements {
+  return name.substring(
+    HTML_ELEMENT_TYPE_PREFIX.length
+  ) as keyof JSX.IntrinsicElements;
+}
diff --git a/plugins/ui/src/js/src/HTMLElementView.tsx b/plugins/ui/src/js/src/HTMLElementView.tsx
new file mode 100644
index 000000000..903e7ddd0
--- /dev/null
+++ b/plugins/ui/src/js/src/HTMLElementView.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { HTMLElementNode, getHTMLTag } from './HTMLElementUtils';
+import { ELEMENT_KEY } from './ElementUtils';
+
+export type HTMLElementViewProps = {
+  element: HTMLElementNode;
+};
+
+export function HTMLElementView({
+  element,
+}: HTMLElementViewProps): JSX.Element | null {
+  const { [ELEMENT_KEY]: name, props = {} } = element;
+  const tag = getHTMLTag(name);
+  if (tag == null) {
+    throw new Error(`Unknown HTML tag ${name}`);
+  }
+  // eslint-disable-next-line react/prop-types
+  const { children, ...otherProps } = props;
+  return React.createElement(tag, otherProps, children);
+}
+
+export default HTMLElementView;
diff --git a/plugins/ui/src/js/src/IconElementUtils.ts b/plugins/ui/src/js/src/IconElementUtils.ts
new file mode 100644
index 000000000..5b9731188
--- /dev/null
+++ b/plugins/ui/src/js/src/IconElementUtils.ts
@@ -0,0 +1,24 @@
+import * as icons from '@deephaven/icons';
+import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils';
+
+export const ICON_ELEMENT_TYPE_PREFIX = 'deephaven.ui.icons.';
+
+export type IconElementName =
+  `${typeof ICON_ELEMENT_TYPE_PREFIX}${keyof typeof icons}`;
+
+export type IconElementNode = ElementNode & {
+  [ELEMENT_KEY]: IconElementName;
+};
+
+export function isIconElementNode(obj: unknown): obj is IconElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as IconElementNode)[ELEMENT_KEY].startsWith(ICON_ELEMENT_TYPE_PREFIX)
+  );
+}
+
+export function getIcon(name: IconElementName): icons.IconDefinition {
+  return icons[
+    name.substring(ICON_ELEMENT_TYPE_PREFIX.length) as keyof typeof icons
+  ] as icons.IconDefinition;
+}
diff --git a/plugins/ui/src/js/src/IconElementView.tsx b/plugins/ui/src/js/src/IconElementView.tsx
new file mode 100644
index 000000000..10ff657eb
--- /dev/null
+++ b/plugins/ui/src/js/src/IconElementView.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { getIcon, IconElementNode } from './IconElementUtils';
+import { ELEMENT_KEY } from './ElementUtils';
+
+export type IconElementViewProps = {
+  element: IconElementNode;
+};
+
+export function IconElementView({
+  element,
+}: IconElementViewProps): JSX.Element | null {
+  const { [ELEMENT_KEY]: name, props = {} } = element;
+  const icon = getIcon(name);
+  if (icon == null) {
+    throw new Error(`Unknown icon ${name}`);
+  }
+
+  return (
+    // eslint-disable-next-line react/jsx-props-no-spreading
+    <FontAwesomeIcon icon={icon} {...props} />
+  );
+}
+
+export default IconElementView;
diff --git a/plugins/ui/src/js/src/ObjectView.tsx b/plugins/ui/src/js/src/ObjectView.tsx
new file mode 100644
index 000000000..c6896aa63
--- /dev/null
+++ b/plugins/ui/src/js/src/ObjectView.tsx
@@ -0,0 +1,49 @@
+import React, { useEffect, useState } from 'react';
+import type { Figure, Table } from '@deephaven/jsapi-types';
+import Log from '@deephaven/log';
+import TableObject from './TableObject';
+import FigureObject from './FigureObject';
+import { ExportedObject } from './ElementUtils';
+
+const log = Log.module('@deephaven/js-plugin-ui/ObjectView');
+
+export interface ObjectViewProps {
+  object: ExportedObject;
+}
+
+function ObjectView(props: ObjectViewProps) {
+  const { object } = props;
+  const [widget, setWidget] = useState<unknown>();
+  log.info('Object is', object);
+
+  // Just load the object on mount
+  useEffect(() => {
+    async function loadWidget() {
+      const newWidget = await object.fetch();
+      setWidget(newWidget);
+    }
+    loadWidget();
+  }, [object]);
+
+  if (widget == null) {
+    // Still loading
+    return null;
+  }
+
+  switch (object.type) {
+    case 'Table':
+    case 'TreeTable':
+    case 'HierarchicalTable':
+      return <TableObject object={widget as Table} />;
+    case 'Figure':
+      return <FigureObject object={widget as Figure} />;
+    default:
+      // TODO: Need to handle other types of objects registered by other plugins (e.g. Deephaven Express)
+      log.warn('Unknown object type', object.type);
+      return <div>Unknown object type: {object.type}</div>;
+  }
+}
+
+ObjectView.displayName = 'ObjectView';
+
+export default ObjectView;
diff --git a/plugins/ui/src/js/src/SpectrumElementUtils.ts b/plugins/ui/src/js/src/SpectrumElementUtils.ts
new file mode 100644
index 000000000..f9ad09b56
--- /dev/null
+++ b/plugins/ui/src/js/src/SpectrumElementUtils.ts
@@ -0,0 +1,70 @@
+import {
+  Checkbox,
+  Content,
+  ContextualHelp,
+  Flex,
+  Grid,
+  Heading,
+  Icon,
+  IllustratedMessage,
+  Switch,
+  Text,
+  ToggleButton,
+  View,
+} from '@adobe/react-spectrum';
+import { ValueOf } from '@deephaven/utils';
+import { ActionButton, Slider, TextField } from './spectrum';
+import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils';
+
+export const SPECTRUM_ELEMENT_TYPE_PREFIX = 'deephaven.ui.spectrum.';
+
+export const SpectrumSupportedTypes = {
+  ActionButton,
+  Checkbox,
+  Content,
+  ContextualHelp,
+  Flex,
+  Grid,
+  Heading,
+  Icon,
+  IllustratedMessage,
+  Slider,
+  Switch,
+  Text,
+  TextField,
+  ToggleButton,
+  View,
+} as const;
+
+export type SpectrumElementName =
+  `${typeof SPECTRUM_ELEMENT_TYPE_PREFIX}${keyof typeof SpectrumSupportedTypes}`;
+
+export type SpectrumElementNode = ElementNode & {
+  [ELEMENT_KEY]: SpectrumElementName;
+};
+
+export function isSpectrumElementNode(
+  obj: unknown
+): obj is SpectrumElementNode {
+  return (
+    isElementNode(obj) &&
+    (obj as SpectrumElementNode)[ELEMENT_KEY].startsWith(
+      SPECTRUM_ELEMENT_TYPE_PREFIX
+    )
+  );
+}
+
+/**
+ * Get the Spectrum Component for the element
+ * @param name Name of the element
+ * @returns The Spectrum Component name for the element
+ */
+export function getSpectrumComponent(
+  name: SpectrumElementName
+): ValueOf<typeof SpectrumSupportedTypes> {
+  return SpectrumSupportedTypes[
+    name.substring(
+      SPECTRUM_ELEMENT_TYPE_PREFIX.length
+    ) as keyof typeof SpectrumSupportedTypes
+  ];
+}
diff --git a/plugins/ui/src/js/src/SpectrumElementView.tsx b/plugins/ui/src/js/src/SpectrumElementView.tsx
new file mode 100644
index 000000000..33cbaef16
--- /dev/null
+++ b/plugins/ui/src/js/src/SpectrumElementView.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import {
+  getSpectrumComponent,
+  SpectrumElementNode,
+} from './SpectrumElementUtils';
+import { ELEMENT_KEY } from './ElementUtils';
+
+export type SpectrumElementViewProps = {
+  element: SpectrumElementNode;
+};
+
+export function SpectrumElementView({
+  element,
+}: SpectrumElementViewProps): JSX.Element | null {
+  const { [ELEMENT_KEY]: name, props = {} } = element;
+  const Component = getSpectrumComponent(name);
+  if (Component == null) {
+    throw new Error(`Unknown Spectrum component ${name}`);
+  }
+  // eslint-disable-next-line react/prop-types
+  const { children, ...otherProps } = props;
+  // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any
+  return <Component {...otherProps}>{children as any}</Component>;
+}
+
+export default SpectrumElementView;
diff --git a/plugins/ui/src/js/src/TableObject.tsx b/plugins/ui/src/js/src/TableObject.tsx
new file mode 100644
index 000000000..5f14e2e4b
--- /dev/null
+++ b/plugins/ui/src/js/src/TableObject.tsx
@@ -0,0 +1,33 @@
+import React, { useEffect, useState } from 'react';
+import {
+  IrisGrid,
+  IrisGridModel,
+  IrisGridModelFactory,
+} from '@deephaven/iris-grid';
+import { useApi } from '@deephaven/jsapi-bootstrap';
+import type { Table } from '@deephaven/jsapi-types';
+
+export interface TableObjectProps {
+  object: Table;
+}
+
+function TableObject(props: TableObjectProps) {
+  const { object } = props;
+  const dh = useApi();
+  const [model, setModel] = useState<IrisGridModel>();
+
+  useEffect(() => {
+    async function loadModel() {
+      const newModel = await IrisGridModelFactory.makeModel(dh, object);
+      setModel(newModel);
+    }
+    loadModel();
+  }, [dh, object]);
+  return (
+    <div className="ui-table-object">{model && <IrisGrid model={model} />}</div>
+  );
+}
+
+TableObject.displayName = 'TableObject';
+
+export default TableObject;
diff --git a/plugins/ui/src/js/src/index.ts b/plugins/ui/src/js/src/index.ts
new file mode 100644
index 000000000..bd84ac498
--- /dev/null
+++ b/plugins/ui/src/js/src/index.ts
@@ -0,0 +1,1 @@
+export * from './DashboardPlugin';
diff --git a/plugins/ui/src/js/src/spectrum/ActionButton.tsx b/plugins/ui/src/js/src/spectrum/ActionButton.tsx
new file mode 100644
index 000000000..a4343526a
--- /dev/null
+++ b/plugins/ui/src/js/src/spectrum/ActionButton.tsx
@@ -0,0 +1,29 @@
+import React, { useCallback } from 'react';
+import {
+  ActionButton as SpectrumActionButton,
+  SpectrumActionButtonProps,
+} from '@adobe/react-spectrum';
+
+function ActionButton(
+  props: SpectrumActionButtonProps & { onPress?: () => void }
+) {
+  const { onPress: propOnPress, ...otherProps } = props;
+
+  const onPress = useCallback(
+    e => {
+      // The PressEvent from React Spectrum is not serializable (contains circular references). We're just dropping the event here but we should probably convert it.
+      // TODO(#76): Need to serialize PressEvent and send with the callback instead of just dropping it.
+      propOnPress?.();
+    },
+    [propOnPress]
+  );
+
+  return (
+    // eslint-disable-next-line react/jsx-props-no-spreading
+    <SpectrumActionButton onPress={onPress} {...otherProps} />
+  );
+}
+
+ActionButton.displayName = 'ActionButton';
+
+export default ActionButton;
diff --git a/plugins/ui/src/js/src/spectrum/Slider.tsx b/plugins/ui/src/js/src/spectrum/Slider.tsx
new file mode 100644
index 000000000..28de56e84
--- /dev/null
+++ b/plugins/ui/src/js/src/spectrum/Slider.tsx
@@ -0,0 +1,43 @@
+import React, { useCallback, useState } from 'react';
+import {
+  Slider as SpectrumSlider,
+  SpectrumSliderProps,
+} from '@adobe/react-spectrum';
+import { useDebouncedCallback } from '@deephaven/react-hooks';
+
+const VALUE_CHANGE_DEBOUNCE = 250;
+
+const EMPTY_FUNCTION = () => undefined;
+
+function Slider(props: SpectrumSliderProps) {
+  const {
+    defaultValue = 0,
+    value: propValue,
+    onChange: propOnChange = EMPTY_FUNCTION,
+    ...otherProps
+  } = props;
+
+  const [value, setValue] = useState(propValue ?? defaultValue);
+
+  const debouncedOnChange = useDebouncedCallback(
+    propOnChange,
+    VALUE_CHANGE_DEBOUNCE
+  );
+
+  const onChange = useCallback(
+    newValue => {
+      setValue(newValue);
+      debouncedOnChange(newValue);
+    },
+    [debouncedOnChange]
+  );
+
+  return (
+    // eslint-disable-next-line react/jsx-props-no-spreading
+    <SpectrumSlider value={value} onChange={onChange} {...otherProps} />
+  );
+}
+
+Slider.displayName = 'Slider';
+
+export default Slider;
diff --git a/plugins/ui/src/js/src/spectrum/TextField.tsx b/plugins/ui/src/js/src/spectrum/TextField.tsx
new file mode 100644
index 000000000..40726a5ba
--- /dev/null
+++ b/plugins/ui/src/js/src/spectrum/TextField.tsx
@@ -0,0 +1,43 @@
+import React, { useCallback, useState } from 'react';
+import {
+  SpectrumTextFieldProps,
+  TextField as SpectrumTextField,
+} from '@adobe/react-spectrum';
+import { useDebouncedCallback } from '@deephaven/react-hooks';
+
+const VALUE_CHANGE_DEBOUNCE = 250;
+
+const EMPTY_FUNCTION = () => undefined;
+
+function TextField(props: SpectrumTextFieldProps) {
+  const {
+    defaultValue = '',
+    value: propValue,
+    onChange: propOnChange = EMPTY_FUNCTION,
+    ...otherProps
+  } = props;
+
+  const [value, setValue] = useState(propValue ?? defaultValue);
+
+  const debouncedOnChange = useDebouncedCallback(
+    propOnChange,
+    VALUE_CHANGE_DEBOUNCE
+  );
+
+  const onChange = useCallback(
+    newValue => {
+      setValue(newValue);
+      debouncedOnChange(newValue);
+    },
+    [debouncedOnChange]
+  );
+
+  return (
+    // eslint-disable-next-line react/jsx-props-no-spreading
+    <SpectrumTextField value={value} onChange={onChange} {...otherProps} />
+  );
+}
+
+TextField.displayName = 'TextField';
+
+export default TextField;
diff --git a/plugins/ui/src/js/src/spectrum/index.ts b/plugins/ui/src/js/src/spectrum/index.ts
new file mode 100644
index 000000000..e56762c45
--- /dev/null
+++ b/plugins/ui/src/js/src/spectrum/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Wrappers for Spectrum components.
+ * Used if we want to mediate some of the props values passed to the Spectrum components.
+ * For example, we may want to debounce sending the change for a text field, and also keep the value on the client side until the change is sent.
+ * Or in the case of event handlers, we may want to wrap the event handler to serialize the event correctly.
+ */
+export { default as ActionButton } from './ActionButton';
+export { default as Slider } from './Slider';
+export { default as TextField } from './TextField';
diff --git a/plugins/ui/src/js/src/styles.scss b/plugins/ui/src/js/src/styles.scss
new file mode 100644
index 000000000..7908eb425
--- /dev/null
+++ b/plugins/ui/src/js/src/styles.scss
@@ -0,0 +1,6 @@
+.ui-table-object {
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+  position: relative;
+}
diff --git a/plugins/ui/src/js/vite.config.ts b/plugins/ui/src/js/vite.config.ts
new file mode 100644
index 000000000..883af3f55
--- /dev/null
+++ b/plugins/ui/src/js/vite.config.ts
@@ -0,0 +1,33 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react-swc';
+
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => ({
+  build: {
+    minify: false,
+    lib: {
+      entry: './src/index.ts',
+      fileName: () => 'index.js',
+      formats: ['cjs'],
+    },
+    rollupOptions: {
+      external: [
+        'react',
+        'react-dom',
+        'redux',
+        'react-redux',
+        '@adobe/react-spectrum',
+        '@deephaven/chart',
+        '@deephaven/components',
+        '@deephaven/icons',
+        '@deephaven/iris-grid',
+        '@deephaven/jsapi-bootstrap',
+        '@deephaven/log',
+      ],
+    },
+  },
+  define:
+    mode === 'production' ? { 'process.env.NODE_ENV': '"production"' } : {},
+  plugins: [react()],
+}));
diff --git a/plugins/ui/src/ui.schema.json b/plugins/ui/src/ui.schema.json
new file mode 100644
index 000000000..41faec78b
--- /dev/null
+++ b/plugins/ui/src/ui.schema.json
@@ -0,0 +1,95 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$defs": {
+    "child": {
+      "anyOf": [
+        { "$ref": "#/defs/element" },
+        { "$ref": "#/defs/exported_object" },
+        { "$ref": "#/defs/callable" },
+        { "type": "number" },
+        { "type": "string" },
+        { "type": "boolean" },
+        { "type": "null" },
+        { "type": "object" },
+        { "type": "any" }
+      ]
+    },
+    "element": {
+      "type": "object",
+      "properties": {
+        "__dh_elem": { "type": "string" },
+        "props": {
+          "type": "object",
+          "properties": {
+            "children": {
+              "anyOf": [
+                {
+                  "type": "array",
+                  "items": { "$ref": "#/defs/child" }
+                },
+                { "$ref": "#/defs/child" }
+              ]
+            }
+          }
+        }
+      }
+    },
+    "callable": {
+      "type": "object",
+      "properties": {
+        "__dh_cbid": { "type": "string" }
+      }
+    },
+    "exportedObject": {
+      "type": "object",
+      "properties": {
+        "__dh_obid": { "type": "number" }
+      }
+    },
+
+    "documentUpdatedParams": {
+      "type": "array",
+      "prefixItems": [{ "$ref": "#/defs/element" }],
+      "items": false
+    }
+  },
+  "type": "object",
+  "properties": {
+    "jsonrpc": "2.0",
+    "method": {
+      "anyOf": [
+        { "enum": ["documentUpdated"] },
+        { "pattern": "^cb_(0-9)+_(0-9)+$" }
+      ]
+    },
+    "allOf": [
+      {
+        "if": {
+          "properties": {
+            "method": { "const": "documentUpdated" }
+          }
+        },
+        "then": {
+          "properties": {
+            "params": { "$ref": "#/defs/documentUpdatedParams" }
+          }
+        }
+      },
+      {
+        "if": {
+          "properties": {
+            "method": { "pattern": "^cb_(0-9)+_(0-9)+$" }
+          }
+        },
+        "then": {
+          "properties": {
+            "params": {
+              "type": "array",
+              "items": { "type": "any" }
+            }
+          }
+        }
+      }
+    ]
+  }
+}
diff --git a/plugins/ui/tox.ini b/plugins/ui/tox.ini
new file mode 100644
index 000000000..b214f738a
--- /dev/null
+++ b/plugins/ui/tox.ini
@@ -0,0 +1,8 @@
+[tox]
+isolated_build = True
+
+[testenv]
+deps =
+    deephaven-server
+commands =
+    python -m unittest discover
\ No newline at end of file


Test patch:
diff --git a/plugins/ui/test/__init__.py b/plugins/ui/test/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/ui/test/deephaven/__init__.py b/plugins/ui/test/deephaven/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/ui/test/deephaven/ui/BaseTest.py b/plugins/ui/test/deephaven/ui/BaseTest.py
new file mode 100644
index 000000000..81a9a3bb1
--- /dev/null
+++ b/plugins/ui/test/deephaven/ui/BaseTest.py
@@ -0,0 +1,34 @@
+import unittest
+from unittest.mock import patch
+
+from deephaven_server import Server
+
+
+class BaseTestCase(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        try:
+            # Use port 11000 so it doesn't conflict with another server
+            cls.s = Server(port=11000, jvm_args=["-Xmx4g"])
+            cls.s.start()
+        except Exception as e:
+            # server is already running
+            pass
+
+        # these mocks need to be setup after the deephaven server is
+        # initialized because they access the deephaven namespace
+        cls.setup_exporter_mock()
+
+    @classmethod
+    @patch("deephaven.plugin.object_type.Exporter")
+    @patch("deephaven.plugin.object_type.Reference")
+    def setup_exporter_mock(cls, MockExporter, MockReference):
+        cls.exporter = MockExporter()
+        cls.reference = MockReference()
+
+        cls.reference.index = 0
+        cls.exporter.reference.return_value = MockReference()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/plugins/ui/test/deephaven/ui/__init__.py b/plugins/ui/test/deephaven/ui/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/ui/test/deephaven/ui/test_encoder.py b/plugins/ui/test/deephaven/ui/test_encoder.py
new file mode 100644
index 000000000..4d798d899
--- /dev/null
+++ b/plugins/ui/test/deephaven/ui/test_encoder.py
@@ -0,0 +1,356 @@
+import json
+import unittest
+from .BaseTest import BaseTestCase
+
+
+def make_node(name: str, props: dict = None):
+    from deephaven.ui.renderer import RenderedNode
+
+    return RenderedNode(name, props)
+
+
+class TestObject:
+    """
+    A test object that can be used to represent an exported object type
+    """
+
+    def __init__(self):
+        pass
+
+
+class EncoderTest(BaseTestCase):
+    def expect_result(
+        self,
+        node,
+        expected_payload: dict,
+        expected_callables: list = [],
+        expected_objects: list = [],
+        callable_id_prefix="cb",
+    ):
+        from deephaven.ui.renderer import NodeEncoder
+
+        encoder = NodeEncoder(callable_id_prefix=callable_id_prefix)
+        payload = encoder.encode(node)
+        self.assertDictEqual(
+            json.loads(payload), expected_payload, "payloads don't match"
+        )
+        self.assertListEqual(
+            encoder.callables, expected_callables, "callables don't match"
+        )
+        self.assertListEqual(encoder.objects, expected_objects, "objects don't match")
+
+    def test_empty_document(self):
+        self.expect_result(make_node(""), {"__dh_elem": ""})
+
+    def test_props(self):
+        self.expect_result(
+            make_node("test_node", {"foo": "bar"}),
+            {"__dh_elem": "test_node", "props": {"foo": "bar"}},
+        )
+
+    def test_child(self):
+        self.expect_result(
+            make_node("test0", {"children": make_node("test1")}),
+            {
+                "__dh_elem": "test0",
+                "props": {"children": {"__dh_elem": "test1"}},
+            },
+        )
+
+    def test_children(self):
+        self.expect_result(
+            make_node(
+                "test0",
+                {
+                    "children": [
+                        make_node(
+                            "test1",
+                            {
+                                "children": [
+                                    make_node(
+                                        "test2", {"children": [make_node("test3")]}
+                                    )
+                                ]
+                            },
+                        ),
+                        make_node(
+                            "test11",
+                            {
+                                "children": [
+                                    make_node(
+                                        "test22", {"children": [make_node("test33")]}
+                                    )
+                                ]
+                            },
+                        ),
+                    ],
+                    "foo": "bar",
+                },
+            ),
+            {
+                "__dh_elem": "test0",
+                "props": {
+                    "children": [
+                        {
+                            "__dh_elem": "test1",
+                            "props": {
+                                "children": [
+                                    {
+                                        "__dh_elem": "test2",
+                                        "props": {
+                                            "children": [{"__dh_elem": "test3"}],
+                                        },
+                                    }
+                                ],
+                            },
+                        },
+                        {
+                            "__dh_elem": "test11",
+                            "props": {
+                                "children": [
+                                    {
+                                        "__dh_elem": "test22",
+                                        "props": {
+                                            "children": [{"__dh_elem": "test33"}],
+                                        },
+                                    }
+                                ],
+                            },
+                        },
+                    ],
+                    "foo": "bar",
+                },
+            },
+        )
+
+    def test_exported_objects(self):
+        obj1 = TestObject()
+
+        self.expect_result(
+            make_node("test_exported", {"children": [obj1]}),
+            {"__dh_elem": "test_exported", "props": {"children": [{"__dh_obid": 0}]}},
+            expected_objects=[obj1],
+        )
+
+    def exported_null(self):
+        self.expect_result(
+            make_node("test_exported", {"children": [None]}),
+            {"__dh_elem": "test_exported", "props": {"children": [None]}},
+        )
+
+    def test_children_with_exported(self):
+        obj1 = TestObject()
+        obj2 = TestObject()
+        obj3 = TestObject()
+
+        # Should use a depth-first traversal to find all exported objects and their indices
+        self.expect_result(
+            make_node(
+                "test0",
+                {
+                    "children": [
+                        make_node("test1", {"children": [obj1]}),
+                        obj2,
+                        make_node("test3", {"children": [obj3]}),
+                    ]
+                },
+            ),
+            {
+                "__dh_elem": "test0",
+                "props": {
+                    "children": [
+                        {
+                            "__dh_elem": "test1",
+                            "props": {"children": [{"__dh_obid": 0}]},
+                        },
+                        {"__dh_obid": 1},
+                        {
+                            "__dh_elem": "test3",
+                            "props": {"children": [{"__dh_obid": 2}]},
+                        },
+                    ],
+                },
+            },
+            expected_objects=[obj1, obj2, obj3],
+        )
+
+    def test_primitive_children(self):
+        self.expect_result(
+            make_node("test0", {"children": ["foo", 1, 2.0]}),
+            {"__dh_elem": "test0", "props": {"children": ["foo", 1, 2.0]}},
+            [],
+        )
+
+    def test_primitives_with_exports(self):
+        obj1 = TestObject()
+        obj2 = TestObject()
+
+        self.expect_result(
+            make_node("test0", {"children": ["foo", obj1, obj2, 2.0]}),
+            {
+                "__dh_elem": "test0",
+                "props": {"children": ["foo", {"__dh_obid": 0}, {"__dh_obid": 1}, 2.0]},
+            },
+            expected_objects=[obj1, obj2],
+        )
+
+    def test_same_object(self):
+        """
+        If the same object is exported multiple times, it should only be exported once and referenced by the same ID.
+        """
+        obj1 = TestObject()
+
+        self.expect_result(
+            make_node("test0", {"children": [obj1, obj1]}),
+            {
+                "__dh_elem": "test0",
+                "props": {"children": [{"__dh_obid": 0}, {"__dh_obid": 0}]},
+            },
+            expected_objects=[obj1],
+        )
+
+    def test_callable(self):
+        cb1 = lambda: None
+
+        self.expect_result(
+            make_node("test0", {"foo": cb1}),
+            {"__dh_elem": "test0", "props": {"foo": {"__dh_cbid": "cb0"}}},
+            expected_callables=[cb1],
+        )
+
+    def test_children_with_callables(self):
+        cb1 = lambda: None
+        cb2 = lambda: None
+        cb3 = lambda: None
+
+        # Should use a depth-first traversal to find all exported objects and their indices
+        self.expect_result(
+            make_node(
+                "test0",
+                {
+                    "children": [
+                        make_node("test1", {"foo": [cb1]}),
+                        cb2,
+                        make_node("test3", {"bar": cb3}),
+                    ]
+                },
+            ),
+            {
+                "__dh_elem": "test0",
+                "props": {
+                    "children": [
+                        {
+                            "__dh_elem": "test1",
+                            "props": {"foo": [{"__dh_cbid": "cb0"}]},
+                        },
+                        {"__dh_cbid": "cb1"},
+                        {
+                            "__dh_elem": "test3",
+                            "props": {"bar": {"__dh_cbid": "cb2"}},
+                        },
+                    ],
+                },
+            },
+            expected_callables=[cb1, cb2, cb3],
+        )
+
+    def test_callables_and_objects(self):
+        cb1 = lambda: None
+        cb2 = lambda: None
+        cb3 = lambda: None
+        obj1 = TestObject()
+        obj2 = TestObject()
+
+        # Should use a depth-first traversal to find all exported objects and their indices
+        self.expect_result(
+            make_node(
+                "test0",
+                {
+                    "children": [
+                        make_node("test1", {"foo": [cb1]}),
+                        cb2,
+                        make_node("test3", {"bar": cb3, "children": [obj1, obj2]}),
+                    ]
+                },
+            ),
+            {
+                "__dh_elem": "test0",
+                "props": {
+                    "children": [
+                        {
+                            "__dh_elem": "test1",
+                            "props": {"foo": [{"__dh_cbid": "cb0"}]},
+                        },
+                        {"__dh_cbid": "cb1"},
+                        {
+                            "__dh_elem": "test3",
+                            "props": {
+                                "bar": {"__dh_cbid": "cb2"},
+                                "children": [
+                                    {"__dh_obid": 0},
+                                    {"__dh_obid": 1},
+                                ],
+                            },
+                        },
+                    ],
+                },
+            },
+            expected_callables=[cb1, cb2, cb3],
+            expected_objects=[obj1, obj2],
+        )
+
+    def test_same_callables(self):
+        """
+        If the same callable is exported multiple times, it should only be exported once and referenced by the same ID.
+        """
+        cb1 = lambda: None
+
+        self.expect_result(
+            make_node("test0", {"foo": [cb1, cb1]}),
+            {
+                "__dh_elem": "test0",
+                "props": {"foo": [{"__dh_cbid": "cb0"}, {"__dh_cbid": "cb0"}]},
+            },
+            expected_callables=[cb1],
+        )
+
+    def test_callable_id_prefix(self):
+        cb1 = lambda: None
+        cb2 = lambda: None
+        cb3 = lambda: None
+
+        # Should use a depth-first traversal to find all exported objects and their indices
+        self.expect_result(
+            make_node(
+                "test0",
+                {
+                    "children": [
+                        make_node("test1", {"foo": [cb1]}),
+                        cb2,
+                        make_node("test3", {"bar": cb3}),
+                    ]
+                },
+            ),
+            {
+                "__dh_elem": "test0",
+                "props": {
+                    "children": [
+                        {
+                            "__dh_elem": "test1",
+                            "props": {"foo": [{"__dh_cbid": "d2c0"}]},
+                        },
+                        {"__dh_cbid": "d2c1"},
+                        {
+                            "__dh_elem": "test3",
+                            "props": {"bar": {"__dh_cbid": "d2c2"}},
+                        },
+                    ],
+                },
+            },
+            expected_callables=[cb1, cb2, cb3],
+            callable_id_prefix="d2c",
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/plugins/ui/test/deephaven/ui/test_hooks.py b/plugins/ui/test/deephaven/ui/test_hooks.py
new file mode 100644
index 000000000..f5ef498dd
--- /dev/null
+++ b/plugins/ui/test/deephaven/ui/test_hooks.py
@@ -0,0 +1,138 @@
+import unittest
+from operator import itemgetter
+from typing import Callable
+from unittest.mock import Mock
+from .BaseTest import BaseTestCase
+
+
+def render_hook(fn: Callable):
+    """
+    Render a hook function and return the context, result, and a rerender function for updating it
+
+    Args:
+      fn: Callable:
+        The function to render. Pass in a function with a hook call within it.
+        Re-render will call the same function but with the new args passed in.
+    """
+    from deephaven.ui._internal.RenderContext import RenderContext
+    from deephaven.ui._internal.shared import get_context, set_context
+
+    context = RenderContext()
+
+    return_dict = {"context": context, "result": None, "rerender": None}
+
+    def _rerender(*args, **kwargs):
+        set_context(context)
+        with context:
+            new_result = fn(*args, **kwargs)
+            return_dict["result"] = new_result
+        return new_result
+
+    return_dict["rerender"] = _rerender
+
+    _rerender()
+
+    return return_dict
+
+
+class HooksTest(BaseTestCase):
+    def test_state(self):
+        from deephaven.ui.hooks import use_state
+
+        def _test_state(value1=1, value2=2):
+            value1, set_value1 = use_state(value1)
+            value2, set_value2 = use_state(value2)
+            return value1, set_value1, value2, set_value2
+
+        # Initial render
+        render_result = render_hook(_test_state)
+
+        result, rerender = itemgetter("result", "rerender")(render_result)
+        val1, set_val1, val2, set_val2 = result
+
+        self.assertEqual(val1, 1)
+        self.assertEqual(val2, 2)
+
+        # Rerender with new values, but should retain existing state
+        rerender(value1=3, value2=4)
+        result = itemgetter("result")(render_result)
+        val1, set_val1, val2, set_val2 = result
+        self.assertEqual(val1, 1)
+        self.assertEqual(val2, 2)
+
+        # Set to a new value
+        set_val1(3)
+        rerender()
+        result = itemgetter("result")(render_result)
+        val1, set_val1, val2, set_val2 = result
+        self.assertEqual(val1, 3)
+        self.assertEqual(val2, 2)
+
+        # Set other state to a new value
+        set_val2(4)
+        rerender()
+        result = itemgetter("result")(render_result)
+        val1, set_val1, val2, set_val2 = result
+        self.assertEqual(val1, 3)
+        self.assertEqual(val2, 4)
+
+    def test_ref(self):
+        from deephaven.ui.hooks import use_ref
+
+        def _test_ref(value=None):
+            ref = use_ref(value)
+            return ref
+
+        # Initial render doesn't set anything
+        render_result = render_hook(_test_ref)
+        result, rerender = itemgetter("result", "rerender")(render_result)
+        self.assertEqual(result.current, None)
+
+        # Doesn't update the value on second call to use_ref
+        result = rerender(1)
+        self.assertEqual(result.current, None)
+
+        # Set the current value, and it should be returned
+        result.current = 2
+        result = rerender(3)
+        self.assertEqual(result.current, 2)
+
+    def test_memo(self):
+        from deephaven.ui.hooks import use_memo
+
+        def _test_memo(fn=lambda: "foo", a=1, b=2):
+            return use_memo(fn, [a, b])
+
+        # Initial render
+        render_result = render_hook(_test_memo)
+        result, rerender = itemgetter("result", "rerender")(render_result)
+        self.assertEqual(result, "foo")
+
+        # Rerender with new function but same deps
+        # Should not re-run the function
+        mock = Mock(return_value="bar")
+        result = rerender(mock)
+        self.assertEqual(result, "foo")
+        self.assertEqual(mock.call_count, 0)
+
+        # Rerender with new deps
+        # Should re-run the function
+        result = rerender(mock, 3, 4)
+        self.assertEqual(result, "bar")
+        self.assertEqual(mock.call_count, 1)
+
+        # Rerender with the same new deps
+        # Should not re-run the function
+        result = rerender(mock, 3, 4)
+        self.assertEqual(result, "bar")
+        self.assertEqual(mock.call_count, 1)
+
+        # Rerender with new deps and new function
+        mock = Mock(return_value="biz")
+        result = rerender(mock, b=4)
+        self.assertEqual(result, "biz")
+        self.assertEqual(mock.call_count, 1)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/plugins/ui/test/deephaven/ui/test_render.py b/plugins/ui/test/deephaven/ui/test_render.py
new file mode 100644
index 000000000..032c5c0bd
--- /dev/null
+++ b/plugins/ui/test/deephaven/ui/test_render.py
@@ -0,0 +1,103 @@
+from unittest.mock import Mock
+from .BaseTest import BaseTestCase
+
+
+class RenderTestCase(BaseTestCase):
+    def test_empty_render(self):
+        from deephaven.ui._internal.RenderContext import RenderContext
+
+        rc = RenderContext()
+        self.assertEqual(rc._hook_index, -1)
+        self.assertEqual(rc._state, {})
+        self.assertEqual(rc._children_context, {})
+        self.assertEqual(rc._on_change(), None)
+
+    def test_hook_index(self):
+        from deephaven.ui._internal.RenderContext import RenderContext
+
+        rc = RenderContext()
+
+        # Set up the hooks used with initial render (3 hooks)
+        with rc:
+            self.assertEqual(rc.next_hook_index(), 0)
+            self.assertEqual(rc.next_hook_index(), 1)
+            self.assertEqual(rc.next_hook_index(), 2)
+
+        # Verify it's the same on the next render
+        with rc:
+            self.assertEqual(rc.next_hook_index(), 0)
+            self.assertEqual(rc.next_hook_index(), 1)
+            self.assertEqual(rc.next_hook_index(), 2)
+
+        # Check that an error is thrown if we don't use enough hooks
+        with self.assertRaises(Exception):
+            with rc:
+                self.assertEqual(rc.next_hook_index(), 0)
+                self.assertEqual(rc.next_hook_index(), 1)
+
+        # Check that an error is thrown if we use too many hooks
+        with self.assertRaises(Exception):
+            with rc:
+                self.assertEqual(rc.next_hook_index(), 0)
+                self.assertEqual(rc.next_hook_index(), 1)
+                self.assertEqual(rc.next_hook_index(), 2)
+                self.assertEqual(rc.next_hook_index(), 3)
+
+    def test_state(self):
+        from deephaven.ui._internal.RenderContext import RenderContext
+
+        rc = RenderContext()
+
+        on_change = Mock()
+        rc.set_on_change(on_change)
+
+        self.assertEqual(rc.has_state(0), False)
+        self.assertEqual(rc.get_state(0), None)
+        self.assertEqual(rc.get_state(0, 1), None)
+        self.assertEqual(rc.get_state(1, 1), 1)
+        self.assertEqual(on_change.call_count, 0)
+
+        rc.set_state(0, 2)
+        self.assertEqual(rc.has_state(0), True)
+        self.assertEqual(rc.get_state(0), 2)
+        self.assertEqual(rc.get_state(0, 1), 2)
+        self.assertEqual(rc.get_state(1), 1)
+        self.assertEqual(rc.get_state(1, 1), 1)
+        self.assertEqual(on_change.call_count, 1)
+
+    def test_context(self):
+        from deephaven.ui._internal.RenderContext import RenderContext
+
+        rc = RenderContext()
+
+        on_change = Mock()
+        rc.set_on_change(on_change)
+
+        child_context0 = rc.get_child_context(0)
+        child_context1 = rc.get_child_context(1)
+
+        self.assertEqual(on_change.call_count, 0)
+
+        # Check that setting the initial state does not trigger a change event
+        rc.set_state(0, 0)
+        self.assertEqual(on_change.call_count, 0)
+
+        # Check that changing state triggers a change event
+        rc.set_state(0, 1)
+        self.assertEqual(on_change.call_count, 1)
+        self.assertEqual(rc.has_state(0), True)
+        self.assertEqual(rc.get_state(0), 1)
+        self.assertEqual(on_change.call_count, 1)
+        self.assertEqual(child_context0.has_state(0), False)
+        self.assertEqual(child_context0.get_state(0), None)
+        child_context0.set_state(0, 2)
+        self.assertEqual(child_context0.has_state(0), True)
+        self.assertEqual(child_context0.get_state(0), 2)
+        self.assertEqual(on_change.call_count, 2)
+        self.assertEqual(child_context1.has_state(0), False)
+        self.assertEqual(child_context1.get_state(0), None)
+        child_context1.set_state(0, 3)
+        self.assertEqual(rc.get_state(0), 1)
+        self.assertEqual(child_context0.get_state(0), 2)
+        self.assertEqual(child_context1.get_state(0), 3)
+        self.assertEqual(on_change.call_count, 3)
diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py
new file mode 100644
index 000000000..1a301d8c1
--- /dev/null
+++ b/plugins/ui/test/deephaven/ui/test_utils.py
@@ -0,0 +1,51 @@
+import unittest
+from .BaseTest import BaseTestCase
+
+
+def my_test_func():
+    raise Exception("should not be called")
+
+
+class UtilsTest(BaseTestCase):
+    def test_get_component_name(self):
+        from deephaven.ui._internal.utils import get_component_name
+
+        self.assertEqual(
+            get_component_name(my_test_func),
+            "test.deephaven.ui.test_utils.my_test_func",
+        )
+
+    def test_to_camel_case(self):
+        from deephaven.ui._internal.utils import to_camel_case
+
+        self.assertEqual(to_camel_case("test_string"), "testString")
+        self.assertEqual(to_camel_case("test_string_2"), "testString2")
+        self.assertEqual(to_camel_case("align_items"), "alignItems")
+        self.assertEqual(to_camel_case("First_Word"), "FirstWord")
+        self.assertEqual(to_camel_case("first_word"), "firstWord")
+        self.assertEqual(to_camel_case("alreadyCamelCase"), "alreadyCamelCase")
+        self.assertEqual(to_camel_case(""), "")
+
+    def test_dict_to_camel_case(self):
+        from deephaven.ui._internal.utils import dict_to_camel_case
+
+        self.assertDictEqual(
+            dict_to_camel_case({"test_string": "foo", "test_string_2": "bar_biz"}),
+            {"testString": "foo", "testString2": "bar_biz"},
+        )
+        self.assertDictEqual(
+            dict_to_camel_case({"alreadyCamelCase": "foo", "align_items": "bar"}),
+            {"alreadyCamelCase": "foo", "alignItems": "bar"},
+        )
+
+    def test_remove_empty_keys(self):
+        from deephaven.ui._internal.utils import remove_empty_keys
+
+        self.assertDictEqual(
+            remove_empty_keys({"foo": "bar", "biz": None, "baz": 0}),
+            {"foo": "bar", "baz": 0},
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()