From 1b01d8d2271dba53245a6bb0025d7f5222797f86 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Fri, 6 Mar 2026 17:43:58 -0800 Subject: [PATCH 1/7] CCV2 Template Tutorials --- .../components-v2/package-based.md | 9 + content/develop/tutorials/_index.md | 8 + .../tutorials/custom-components/_index.md | 30 + .../custom-components/template-react.md | 703 ++++++++++++++++++ .../custom-components/template-typescript.md | 530 +++++++++++++ content/menu.md | 6 + 6 files changed, 1286 insertions(+) create mode 100644 content/develop/tutorials/custom-components/_index.md create mode 100644 content/develop/tutorials/custom-components/template-react.md create mode 100644 content/develop/tutorials/custom-components/template-typescript.md diff --git a/content/develop/concepts/custom-components/components-v2/package-based.md b/content/develop/concepts/custom-components/components-v2/package-based.md index d7597b701..d369f58af 100644 --- a/content/develop/concepts/custom-components/components-v2/package-based.md +++ b/content/develop/concepts/custom-components/components-v2/package-based.md @@ -22,6 +22,15 @@ Choose package-based components when you need one of the following features: The fastest way to create a package-based component is with the official [component template](https://github.com/streamlit/component-template). It generates a complete project with all the configuration, build tooling, and boilerplate you need. + + +For step-by-step walkthroughs of using the template, see the tutorials: + +- [Create a component with Pure TypeScript](/develop/tutorials/custom-components/template-typescript) +- [Create a component with React + TypeScript](/develop/tutorials/custom-components/template-react) + + + ### Prerequisites - Python >= 3.10 diff --git a/content/develop/tutorials/_index.md b/content/develop/tutorials/_index.md index d937a69ce..d9ba06bee 100644 --- a/content/develop/tutorials/_index.md +++ b/content/develop/tutorials/_index.md @@ -59,6 +59,14 @@ Build simple apps and walk through examples to learn about Streamlit's core feat + + +
Build custom components
+ +Build package-based custom components with the official component template. + +
+
Create multipage apps
diff --git a/content/develop/tutorials/custom-components/_index.md b/content/develop/tutorials/custom-components/_index.md new file mode 100644 index 000000000..5255b8270 --- /dev/null +++ b/content/develop/tutorials/custom-components/_index.md @@ -0,0 +1,30 @@ +--- +title: Build custom components +slug: /develop/tutorials/custom-components +description: Step-by-step tutorials for building Streamlit custom components with the official component template. +keywords: custom components v2, tutorials, TypeScript, React, template, package-based +--- + +# Build custom components + +Build package-based Streamlit custom components using the official [component template](https://github.com/streamlit/component-template). These tutorials walk you through generating a project, understanding the generated code, and extending the template. + + + + + +
Create a component with Pure TypeScript
+ +Build a package-based component using Pure TypeScript and Vite. Best for lightweight components without framework overhead. + +
+ + + +
Create a component with React + TypeScript
+ +Build a package-based component using React, TypeScript, and Vite. Best for components with complex, state-driven UIs. + +
+ +
diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md new file mode 100644 index 000000000..4c516846c --- /dev/null +++ b/content/develop/tutorials/custom-components/template-react.md @@ -0,0 +1,703 @@ +--- +title: Create a component with React + TypeScript +slug: /develop/tutorials/custom-components/template-react +description: Build a package-based Streamlit custom component using the official template with React, TypeScript, and Vite. +keywords: custom components v2, tutorial, React, TypeScript, template, cookiecutter, package-based, Vite, component development +--- + +# Create a component with React + TypeScript + +In this tutorial, you'll use the official [component template](https://github.com/streamlit/component-template) to generate a React-based custom component. You'll learn how React integrates with Streamlit's component lifecycle, how to manage the React root, and how to extend the template with React hooks and JSX. + +## Prerequisites + +- The following versions are required: + + ```text + python>=3.10 + node>=24 + streamlit>=1.51.0 + ``` + +- [uv](https://docs.astral.sh/uv/) (recommended Python package manager) +- npm (included with Node.js) +- Familiarity with [React](https://react.dev/) basics (components, hooks, JSX) +- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) + +## Summary + +The template generates a working "Hello, World!" component with a click counter built using React. You'll walk through the generated code, then extend it to render a dynamic list of items from Python data. + +Here's a look at what you'll build: + + + +Directory structure: + +```none +my-react-counter/ +├── pyproject.toml +├── example.py +└── my_react_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + ├── index.tsx + └── MyComponent.tsx +``` + +`my_react_counter/__init__.py`: + +```python +import streamlit as st + +out = st.components.v2.component( + "my-react-counter.my_react_counter", + js="index-*.js", + html='
', +) + + +def my_component(name, items=None, key=None, on_item_clicked=lambda: None): + component_value = out( + key=key, + default={"num_clicks": 0, "selected_item": None}, + data={"name": name, "items": items or []}, + on_num_clicks_change=lambda: None, + on_selected_item_change=lambda: None, + on_item_clicked_change=on_item_clicked, + ) + return component_value +``` + +`my_react_counter/frontend/src/index.tsx`: + +```typescript +import { + FrontendRenderer, + FrontendRendererArgs, +} from "@streamlit/component-v2-lib"; +import { StrictMode } from "react"; +import { createRoot, Root } from "react-dom/client"; + +import MyComponent, { + MyComponentDataShape, + MyComponentStateShape, +} from "./MyComponent"; + +const reactRoots: WeakMap< + FrontendRendererArgs["parentElement"], + Root +> = new WeakMap(); + +const MyComponentRoot: FrontendRenderer< + MyComponentStateShape, + MyComponentDataShape +> = (args) => { + const { data, parentElement, setStateValue, setTriggerValue } = args; + + const rootElement = parentElement.querySelector(".react-root"); + if (!rootElement) { + throw new Error("Unexpected: React root element not found"); + } + + let reactRoot = reactRoots.get(parentElement); + if (!reactRoot) { + reactRoot = createRoot(rootElement); + reactRoots.set(parentElement, reactRoot); + } + + const { name, items } = data; + + reactRoot.render( + + + , + ); + + return () => { + const reactRoot = reactRoots.get(parentElement); + if (reactRoot) { + reactRoot.unmount(); + reactRoots.delete(parentElement); + } + }; +}; + +export default MyComponentRoot; +``` + +`my_react_counter/frontend/src/MyComponent.tsx`: + +```typescript +import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; +import { FC, ReactElement, useCallback, useState } from "react"; + +export type MyComponentStateShape = { + num_clicks: number; + selected_item: string | null; +}; + +export type MyComponentDataShape = { + name: string; + items: string[]; +}; + +export type MyComponentProps = Pick< + FrontendRendererArgs, + "setStateValue" | "setTriggerValue" +> & + MyComponentDataShape; + +const MyComponent: FC = ({ + name, + items, + setStateValue, + setTriggerValue, +}): ReactElement => { + const [numClicks, setNumClicks] = useState(0); + const [selectedItem, setSelectedItem] = useState(null); + + const onClicked = useCallback((): void => { + const newNumClicks = numClicks + 1; + setNumClicks(newNumClicks); + setStateValue("num_clicks", newNumClicks); + }, [numClicks, setStateValue]); + + const onItemSelected = useCallback( + (item: string): void => { + setSelectedItem(item); + setStateValue("selected_item", item); + setTriggerValue("item_clicked", item); + }, + [setStateValue, setTriggerValue], + ); + + return ( +
+

Hello, {name}!

+ + {items && items.length > 0 && ( +
    + {items.map((item) => ( +
  • onItemSelected(item)} + style={{ + padding: "0.5rem 1rem", + margin: "0.25rem 0", + background: + selectedItem === item + ? "var(--st-primary-color)" + : "var(--st-secondary-background-color)", + color: selectedItem === item ? "white" : "inherit", + borderRadius: "var(--st-base-radius)", + cursor: "pointer", + border: `1px solid var(--st-border-color)`, + }} + > + {item} +
  • + ))} +
+ )} +
+ ); +}; + +export default MyComponent; +``` + +`example.py`: + +```python +import streamlit as st +from my_react_counter import my_component + +st.title("My React Counter") + +result = my_component( + "Streamlit", + items=["Python", "TypeScript", "React", "Vite"], + key="counter", +) + +st.write(f"Click count: {result.num_clicks}") +if result.selected_item: + st.write(f"Selected: {result.selected_item}") +if result.item_clicked: + st.write(f"Just clicked: {result.item_clicked}") +``` + +
+ +## Generate the project + +1. Navigate to the directory where you want to create your project and run the cookiecutter generator. The generator will create a new subdirectory for your project. + + ```bash + uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2 + ``` + +1. Follow the interactive prompts. When asked for the framework, choose **React + Typescript**: + + ``` + author_name [John Smith]: Your Name + author_email [john@example.com]: you@example.com + project_name [Streamlit Component X]: My React Counter + package_name [my-react-counter]: + import_name [my_react_counter]: + description [Streamlit component that allows you to do X]: A React-based counter component + Select open_source_license: + ... + Select framework: + 1 - React + Typescript + 2 - Pure Typescript + Choose from 1, 2 [1]: 1 + ``` + + This creates a `my-react-counter/` directory with the following structure: + + ``` + my-react-counter/ + ├── pyproject.toml + ├── LICENSE + ├── README.md + ├── example.py + └── my_react_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + ├── index.tsx + └── MyComponent.tsx + ``` + + Notice the React template has two frontend source files instead of one: `index.tsx` handles integration with Streamlit's lifecycle, and `MyComponent.tsx` contains the React component. + +## Run the template + +You need two terminals running in parallel for development. + +1. In the first terminal, navigate into your new project directory and install the Python package in editable mode: + + ```bash + cd my-react-counter + uv pip install -e . + ``` + +1. In the same terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: + + ```bash + cd my_react_counter/frontend + npm install + npm run dev + ``` + +1. In a second terminal, navigate to the project root and run the example app: + + ```bash + cd my-react-counter + streamlit run example.py + ``` + +1. View your running app. + + You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. + +## Understand the generated code + +The Python side (`__init__.py` and `pyproject.toml` files) is identical to the Pure TypeScript template. See [Package-based components](/develop/concepts/custom-components/components-v2/package-based#understanding-the-project-structure) for details on those files. This section focuses on the React-specific frontend code. + +1. Open `my_react_counter/frontend/src/index.tsx`: + + ```typescript + import { + FrontendRenderer, + FrontendRendererArgs, + } from "@streamlit/component-v2-lib"; + import { StrictMode } from "react"; + import { createRoot, Root } from "react-dom/client"; + + import MyComponent, { + MyComponentDataShape, + MyComponentStateShape, + } from "./MyComponent"; + + const reactRoots: WeakMap< + FrontendRendererArgs["parentElement"], + Root + > = new WeakMap(); + + const MyComponentRoot: FrontendRenderer< + MyComponentStateShape, + MyComponentDataShape + > = (args) => { + const { data, parentElement, setStateValue } = args; + + const rootElement = parentElement.querySelector(".react-root"); + if (!rootElement) { + throw new Error("Unexpected: React root element not found"); + } + + let reactRoot = reactRoots.get(parentElement); + if (!reactRoot) { + reactRoot = createRoot(rootElement); + reactRoots.set(parentElement, reactRoot); + } + + const { name } = data; + + reactRoot.render( + + + , + ); + + return () => { + const reactRoot = reactRoots.get(parentElement); + if (reactRoot) { + reactRoot.unmount(); + reactRoots.delete(parentElement); + } + }; + }; + + export default MyComponentRoot; + ``` + + This file is the bridge between Streamlit's component lifecycle and React. The pattern is different from a typical React app: + - **React root management**: Streamlit calls your `FrontendRenderer` function on every re-render (whenever `data` changes). You can't create a new React root each time; instead, the `WeakMap` stores one root per component instance (keyed by `parentElement`). On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. + - **Passing props**: The bridge extracts `data` and `setStateValue` from Streamlit's args and passes them as React props to `MyComponent`. This is where you decide which Streamlit args your React component needs. + - **Cleanup**: The returned function unmounts the React root when Streamlit removes the component from the page. + +1. Open `my_react_counter/frontend/src/MyComponent.tsx`: + + ```typescript + import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; + import { + CSSProperties, + FC, + ReactElement, + useCallback, + useMemo, + useState, + } from "react"; + + export type MyComponentStateShape = { + num_clicks: number; + }; + + export type MyComponentDataShape = { + name: string; + }; + + export type MyComponentProps = Pick< + FrontendRendererArgs, + "setStateValue" + > & + MyComponentDataShape; + + const MyComponent: FC = ({ + name, + setStateValue, + }): ReactElement => { + const [isFocused, setIsFocused] = useState(false); + const [numClicks, setNumClicks] = useState(0); + + const style = useMemo(() => { + const colorToUse = isFocused + ? "var(--st-primary-color)" + : "var(--st-gray-color)"; + return { + border: `1px solid ${colorToUse}`, + outline: `1px solid ${colorToUse}`, + }; + }, [isFocused]); + + const onClicked = useCallback((): void => { + const newNumClicks = numClicks + 1; + setNumClicks(newNumClicks); + setStateValue("num_clicks", newNumClicks); + }, [numClicks, setStateValue]); + + return ( + +

Hello, {name}!

+ +
+ ); + }; + + export default MyComponent; + ``` + + This is a standard React functional component. Note the following: + - **Type-safe props**: `MyComponentProps` is constructed from `FrontendRendererArgs` using TypeScript's `Pick` utility type. This ensures the `setStateValue` prop is correctly typed for the component's state shape. + - **React state management**: Local UI state (like `isFocused`) is managed with React's `useState` hook. This state is purely for the frontend and doesn't need to go back to Python. + - **Communicating with Python**: When the button is clicked, `setStateValue("num_clicks", newNumClicks)` sends the count back to Streamlit. This triggers a Python rerun, just like in non-React components. + - **Streamlit theming**: The component uses CSS custom properties like `var(--st-primary-color)` directly in inline styles. These properties are provided by Streamlit's theme system and work inside the component's shadow DOM. + +## Modify the component + +Now extend the template to render a dynamic list of items from Python data. This showcases something React does well: declaratively rendering lists with state. + +1. In `my_react_counter/frontend/src/MyComponent.tsx`, replace the file contents with the following: + + ```typescript + import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; + import { FC, ReactElement, useCallback, useState } from "react"; + + export type MyComponentStateShape = { + num_clicks: number; + selected_item: string | null; + }; + + export type MyComponentDataShape = { + name: string; + items: string[]; + }; + + export type MyComponentProps = Pick< + FrontendRendererArgs, + "setStateValue" | "setTriggerValue" + > & + MyComponentDataShape; + + const MyComponent: FC = ({ + name, + items, + setStateValue, + setTriggerValue, + }): ReactElement => { + const [numClicks, setNumClicks] = useState(0); + const [selectedItem, setSelectedItem] = useState(null); + + const onClicked = useCallback((): void => { + const newNumClicks = numClicks + 1; + setNumClicks(newNumClicks); + setStateValue("num_clicks", newNumClicks); + }, [numClicks, setStateValue]); + + const onItemSelected = useCallback( + (item: string): void => { + setSelectedItem(item); + setStateValue("selected_item", item); + setTriggerValue("item_clicked", item); + }, + [setStateValue, setTriggerValue], + ); + + return ( +
+

Hello, {name}!

+ + {items && items.length > 0 && ( +
    + {items.map((item) => ( +
  • onItemSelected(item)} + style={{ + padding: "0.5rem 1rem", + margin: "0.25rem 0", + background: + selectedItem === item + ? "var(--st-primary-color)" + : "var(--st-secondary-background-color)", + color: selectedItem === item ? "white" : "inherit", + borderRadius: "var(--st-base-radius)", + cursor: "pointer", + border: `1px solid var(--st-border-color)`, + }} + > + {item} +
  • + ))} +
+ )} +
+ ); + }; + + export default MyComponent; + ``` + +1. In `my_react_counter/frontend/src/index.tsx`, replace the file contents with the following to pass the new props: + + ```typescript + import { + FrontendRenderer, + FrontendRendererArgs, + } from "@streamlit/component-v2-lib"; + import { StrictMode } from "react"; + import { createRoot, Root } from "react-dom/client"; + + import MyComponent, { + MyComponentDataShape, + MyComponentStateShape, + } from "./MyComponent"; + + const reactRoots: WeakMap< + FrontendRendererArgs["parentElement"], + Root + > = new WeakMap(); + + const MyComponentRoot: FrontendRenderer< + MyComponentStateShape, + MyComponentDataShape + > = (args) => { + const { data, parentElement, setStateValue, setTriggerValue } = args; + + const rootElement = parentElement.querySelector(".react-root"); + if (!rootElement) { + throw new Error("Unexpected: React root element not found"); + } + + let reactRoot = reactRoots.get(parentElement); + if (!reactRoot) { + reactRoot = createRoot(rootElement); + reactRoots.set(parentElement, reactRoot); + } + + const { name, items } = data; + + reactRoot.render( + + + , + ); + + return () => { + const reactRoot = reactRoots.get(parentElement); + if (reactRoot) { + reactRoot.unmount(); + reactRoots.delete(parentElement); + } + }; + }; + + export default MyComponentRoot; + ``` + +1. In `my_react_counter/__init__.py`, replace the file contents with the following to pass items and handle the new callbacks: + + ```python + import streamlit as st + + out = st.components.v2.component( + "my-react-counter.my_react_counter", + js="index-*.js", + html='
', + ) + + + def my_component(name, items=None, key=None, on_item_clicked=lambda: None): + component_value = out( + key=key, + default={"num_clicks": 0, "selected_item": None}, + data={"name": name, "items": items or []}, + on_num_clicks_change=lambda: None, + on_selected_item_change=lambda: None, + on_item_clicked_change=on_item_clicked, + ) + return component_value + ``` + +1. To exercise the new list feature, replace the contents of `example.py` with the following: + + ```python + import streamlit as st + from my_react_counter import my_component + + st.title("My React Counter") + + result = my_component( + "Streamlit", + items=["Python", "TypeScript", "React", "Vite"], + key="counter", + ) + + st.write(f"Click count: {result.num_clicks}") + if result.selected_item: + st.write(f"Selected: {result.selected_item}") + if result.item_clicked: + st.write(f"Just clicked: {result.item_clicked}") + ``` + +1. If `npm run dev` is still running, the frontend rebuilds automatically. Save your files, refresh your Streamlit app, and view the updated component with a clickable list. + +## Build for production + +When you're ready to share your component, create a production build. + +1. In a terminal, navigate to the frontend directory and build the frontend: + + ```bash + cd my-react-counter/my_react_counter/frontend + npm run build + ``` + +1. Navigate to the project root and build the Python wheel: + + ```bash + cd ../../.. + uv build + ``` + + This creates a `.whl` file in the `dist/` directory that you can distribute or upload to PyPI. For publishing instructions, see [Publish a Component](/develop/concepts/custom-components/publish). + +## What's next? + +- Learn more about the project structure in [Package-based components](/develop/concepts/custom-components/components-v2/package-based). +- Understand [State vs trigger values](/develop/concepts/custom-components/components-v2/state-and-triggers) for interactive components. +- Explore [Theming and styling](/develop/concepts/custom-components/components-v2/theming) to use Streamlit's CSS custom properties. +- Try the [Pure TypeScript tutorial](/develop/tutorials/custom-components/template-typescript) if you want a lighter-weight approach without React. diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md new file mode 100644 index 000000000..b4a3c8b3e --- /dev/null +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -0,0 +1,530 @@ +--- +title: Create a component with Pure TypeScript +slug: /develop/tutorials/custom-components/template-typescript +description: Build a package-based Streamlit custom component using the official template with Pure TypeScript and Vite. +keywords: custom components v2, tutorial, TypeScript, template, cookiecutter, package-based, Vite, component development +--- + +# Create a component with Pure TypeScript + +In this tutorial, you'll use the official [component template](https://github.com/streamlit/component-template) to generate a package-based custom component, understand how each piece works, and modify the component to make it your own. + +## Prerequisites + +- The following versions are required: + + ```text + python>=3.10 + node>=24 + streamlit>=1.51.0 + ``` + +- [uv](https://docs.astral.sh/uv/) (recommended Python package manager) +- npm (included with Node.js) +- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) + +## Summary + +The template generates a working "Hello, World!" component with a click counter. You'll walk through the generated code, then extend it to add a reset button with a trigger value. + +Here's a look at what you'll build: + + + +Directory structure: + +```none +my-click-counter/ +├── pyproject.toml +├── example.py +└── my_click_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + └── index.ts +``` + +`my_click_counter/__init__.py`: + +```python +import streamlit as st + +out = st.components.v2.component( + "my-click-counter.my_click_counter", + js="index-*.js", + html=""" +
+

Hello, World!

+
+ + +
+

+
+ """, +) + + +def my_component(name, key=None, on_reset=lambda: None): + component_value = out( + key=key, + default={"num_clicks": 0}, + data={"name": name}, + on_num_clicks_change=lambda: None, + on_was_reset_change=on_reset, + ) + return component_value +``` + +`my_click_counter/frontend/src/index.ts`: + +```typescript +import { + FrontendRenderer, + FrontendRendererArgs, +} from "@streamlit/component-v2-lib"; + +export type FrontendState = { + num_clicks: number; +}; + +export type ComponentData = { + name: string; +}; + +const instances: WeakMap< + FrontendRendererArgs["parentElement"], + { numClicks: number } +> = new WeakMap(); + +const MyComponent: FrontendRenderer = (args) => { + const { parentElement, data, setStateValue, setTriggerValue } = args; + + const rootElement = parentElement.querySelector(".component-root"); + if (!rootElement) { + throw new Error("Unexpected: root element not found"); + } + + const heading = rootElement.querySelector("h1"); + if (heading) { + heading.textContent = `Hello, ${data.name}!`; + } + + const incrementBtn = + rootElement.querySelector("#increment"); + const resetBtn = rootElement.querySelector("#reset"); + const countDisplay = rootElement.querySelector("#count"); + + if (!incrementBtn || !resetBtn || !countDisplay) { + throw new Error("Unexpected: required elements not found"); + } + + const currentCount = instances.get(parentElement)?.numClicks || 0; + countDisplay.textContent = `Clicks: ${currentCount}`; + + const handleIncrement = () => { + const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; + instances.set(parentElement, { numClicks }); + countDisplay.textContent = `Clicks: ${numClicks}`; + setStateValue("num_clicks", numClicks); + }; + + const handleReset = () => { + instances.set(parentElement, { numClicks: 0 }); + countDisplay.textContent = `Clicks: 0`; + setStateValue("num_clicks", 0); + setTriggerValue("was_reset", true); + }; + + if (!instances.has(parentElement)) { + incrementBtn.addEventListener("click", handleIncrement); + resetBtn.addEventListener("click", handleReset); + instances.set(parentElement, { numClicks: 0 }); + } + + return () => { + incrementBtn.removeEventListener("click", handleIncrement); + resetBtn.removeEventListener("click", handleReset); + instances.delete(parentElement); + }; +}; + +export default MyComponent; +``` + +`example.py`: + +```python +import streamlit as st +from my_click_counter import my_component + +st.title("My Click Counter") + +def handle_reset(): + st.toast("Counter was reset!") + +result = my_component("Streamlit", key="counter", on_reset=handle_reset) + +st.write(f"Click count: {result.num_clicks}") +if result.was_reset: + st.write("The counter was just reset.") +``` + +
+ +## Generate the project + +1. Navigate to the directory where you want to create your project and run the cookiecutter generator. The generator will create a new subdirectory for your project. + + ```bash + uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2 + ``` + +1. Follow the interactive prompts. When asked for the framework, choose **Pure Typescript**: + + ``` + author_name [John Smith]: Your Name + author_email [john@example.com]: you@example.com + project_name [Streamlit Component X]: My Click Counter + package_name [my-click-counter]: + import_name [my_click_counter]: + description [Streamlit component that allows you to do X]: A click counter component + Select open_source_license: + ... + Select framework: + 1 - React + Typescript + 2 - Pure Typescript + Choose from 1, 2 [1]: 2 + ``` + + This creates a `my-click-counter/` directory with the following structure: + + ``` + my-click-counter/ + ├── pyproject.toml + ├── LICENSE + ├── README.md + ├── example.py + └── my_click_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + └── index.ts + ``` + +## Run the template + +You need two terminals running in parallel for development. + +1. In the first terminal, navigate into your new project directory and install the Python package in editable mode: + + ```bash + cd my-click-counter + uv pip install -e . + ``` + +1. In the same terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: + + ```bash + cd my_click_counter/frontend + npm install + npm run dev + ``` + +1. In a second terminal, navigate to the project root and run the example app: + + ```bash + cd my-click-counter + streamlit run example.py + ``` + +1. View your running app. + + You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. + +## Understand the generated code + +Now that the component is running, walk through each file to understand how it works. + +1. Open `my_click_counter/__init__.py`: + + ```python + import streamlit as st + + out = st.components.v2.component( + "my-click-counter.my_click_counter", + js="index-*.js", + html=""" +
+

Hello, World!

+ +
+ """, + ) + + + def on_num_clicks_change(): + pass + + + def my_component(name, key=None): + component_value = out( + name=name, + key=key, + default={"num_clicks": 0}, + data={"name": name}, + on_num_clicks_change=on_num_clicks_change, + ) + return component_value + ``` + + This file does two things: + - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) that matches the metadata in the `pyproject.toml` files. The `js` parameter uses a glob pattern to match the hashed build output, and `html` provides the initial markup. + + - **Defines a wrapper function** (`my_component`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount). + +1. Open `my_click_counter/frontend/src/index.ts`: + + ```typescript + import { + FrontendRenderer, + FrontendRendererArgs, + } from "@streamlit/component-v2-lib"; + + export type FrontendState = { + num_clicks: number; + }; + + export type ComponentData = { + name: string; + }; + + const instances: WeakMap< + FrontendRendererArgs["parentElement"], + { numClicks: number } + > = new WeakMap(); + + const MyComponent: FrontendRenderer = ( + args, + ) => { + const { parentElement, data, setStateValue } = args; + + const rootElement = parentElement.querySelector(".component-root"); + if (!rootElement) { + throw new Error("Unexpected: root element not found"); + } + + const heading = rootElement.querySelector("h1"); + if (heading) { + heading.textContent = `Hello, ${data.name}!`; + } + + const button = rootElement.querySelector("button"); + if (!button) { + throw new Error("Unexpected: button element not found"); + } + + const handleClick = () => { + const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; + instances.set(parentElement, { numClicks }); + setStateValue("num_clicks", numClicks); + }; + + if (!instances.has(parentElement)) { + button.addEventListener("click", handleClick); + instances.set(parentElement, { numClicks: 0 }); + } + + return () => { + button.removeEventListener("click", handleClick); + instances.delete(parentElement); + }; + }; + + export default MyComponent; + ``` + + This follows the same pattern as inline components, but with TypeScript types. Here are the key pieces: + - **Type definitions**: `FrontendState` and `ComponentData` define the shape of the component's state and the data it receives from Python. These are used as generic parameters on `FrontendRenderer` for type safety. + - **Instance tracking**: The `WeakMap` tracks per-instance state (the click count) across re-renders. Since Streamlit calls your function on every re-render, you need a way to persist state between calls without re-adding event listeners. + - **`setStateValue`**: Sends the updated click count back to Python. This triggers a rerun, just like in inline components. + - **Cleanup function**: The returned function removes event listeners when the component is unmounted. + +The `vite.config.ts` builds your TypeScript into an ES module with a hashed filename (like `index-a1b2c3d4.js`). The `pyproject.toml` files tell setuptools to include these build artifacts in the Python package, and tell Streamlit where to find and serve them. For a detailed explanation of each configuration file, see [Package-based components](/develop/concepts/custom-components/components-v2/package-based#understanding-the-project-structure). + +## Modify the component + +Now extend the template to add a reset button and a trigger value that fires when the counter is reset. + +1. In `my_click_counter/__init__.py`, replace the `html` parameter with the following to add a reset button and a count display: + + ```python + out = st.components.v2.component( + "my-click-counter.my_click_counter", + js="index-*.js", + html=""" +
+

Hello, World!

+
+ + +
+

+
+ """, + ) + ``` + +1. In `my_click_counter/frontend/src/index.ts`, replace the file contents with the following to handle both buttons: + + ```typescript + import { + FrontendRenderer, + FrontendRendererArgs, + } from "@streamlit/component-v2-lib"; + + export type FrontendState = { + num_clicks: number; + }; + + export type ComponentData = { + name: string; + }; + + const instances: WeakMap< + FrontendRendererArgs["parentElement"], + { numClicks: number } + > = new WeakMap(); + + const MyComponent: FrontendRenderer = ( + args, + ) => { + const { parentElement, data, setStateValue, setTriggerValue } = args; + + const rootElement = parentElement.querySelector(".component-root"); + if (!rootElement) { + throw new Error("Unexpected: root element not found"); + } + + const heading = rootElement.querySelector("h1"); + if (heading) { + heading.textContent = `Hello, ${data.name}!`; + } + + const incrementBtn = + rootElement.querySelector("#increment"); + const resetBtn = rootElement.querySelector("#reset"); + const countDisplay = rootElement.querySelector("#count"); + + if (!incrementBtn || !resetBtn || !countDisplay) { + throw new Error("Unexpected: required elements not found"); + } + + const currentCount = instances.get(parentElement)?.numClicks || 0; + countDisplay.textContent = `Clicks: ${currentCount}`; + + const handleIncrement = () => { + const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; + instances.set(parentElement, { numClicks }); + countDisplay.textContent = `Clicks: ${numClicks}`; + setStateValue("num_clicks", numClicks); + }; + + const handleReset = () => { + instances.set(parentElement, { numClicks: 0 }); + countDisplay.textContent = `Clicks: 0`; + setStateValue("num_clicks", 0); + setTriggerValue("was_reset", true); + }; + + if (!instances.has(parentElement)) { + incrementBtn.addEventListener("click", handleIncrement); + resetBtn.addEventListener("click", handleReset); + instances.set(parentElement, { numClicks: 0 }); + } + + return () => { + incrementBtn.removeEventListener("click", handleIncrement); + resetBtn.removeEventListener("click", handleReset); + instances.delete(parentElement); + }; + }; + + export default MyComponent; + ``` + + The key changes are: + - Added `setTriggerValue` to the destructured args. Unlike `setStateValue`, trigger values are transient and reset to `None` after each rerun. + - Added a reset handler that sets the count back to zero and fires a `"was_reset"` trigger. + - Added a count display that updates on each click. + +1. In `my_click_counter/__init__.py`, replace the wrapper function with the following to handle the new trigger: + + ```python + def my_component(name, key=None, on_reset=lambda: None): + component_value = out( + key=key, + default={"num_clicks": 0}, + data={"name": name}, + on_num_clicks_change=lambda: None, + on_was_reset_change=on_reset, + ) + return component_value + ``` + +1. If `npm run dev` is still running, the frontend rebuilds automatically. Refresh your Streamlit app to see the changes. + +1. To exercise the new functionality, replace the contents of `example.py` with the following: + + ```python + import streamlit as st + from my_click_counter import my_component + + st.title("My Click Counter") + + def handle_reset(): + st.toast("Counter was reset!") + + result = my_component("Streamlit", key="counter", on_reset=handle_reset) + + st.write(f"Click count: {result.num_clicks}") + if result.was_reset: + st.write("The counter was just reset.") + ``` + +1. Save your file and view your running app. + +## Build for production + +When you're ready to share your component, create a production build. + +1. In a terminal, navigate to the frontend directory and build the frontend: + + ```bash + cd my-click-counter/my_click_counter/frontend + npm run build + ``` + +1. Navigate to the project root and build the Python wheel: + + ```bash + cd ../../.. + uv build + ``` + + This creates a `.whl` file in the `dist/` directory that you can distribute or upload to PyPI. For publishing instructions, see [Publish a Component](/develop/concepts/custom-components/publish). + +## What's next? + +- Learn more about the project structure in [Package-based components](/develop/concepts/custom-components/components-v2/package-based). +- Understand [State vs trigger values](/develop/concepts/custom-components/components-v2/state-and-triggers) for interactive components. +- Explore [Theming and styling](/develop/concepts/custom-components/components-v2/theming) to use Streamlit's CSS custom properties. +- Try the [React + TypeScript tutorial](/develop/tutorials/custom-components/template-react) if you want to use React. diff --git a/content/menu.md b/content/menu.md index 1611c511a..23570aab6 100644 --- a/content/menu.md +++ b/content/menu.md @@ -671,6 +671,12 @@ site_menu: url: /develop/tutorials/execution-flow/create-a-multiple-container-fragment - category: Develop / Tutorials / Execution flow / Start and stop a streaming fragment url: /develop/tutorials/execution-flow/start-and-stop-fragment-auto-reruns + - category: Develop / Tutorials / Build custom components + url: /develop/tutorials/custom-components + - category: Develop / Tutorials / Build custom components / Create a component with Pure TypeScript + url: /develop/tutorials/custom-components/template-typescript + - category: Develop / Tutorials / Build custom components / Create a component with React + TypeScript + url: /develop/tutorials/custom-components/template-react - category: Develop / Tutorials / Multipage apps url: /develop/tutorials/multipage - category: Develop / Tutorials / Multipage apps / Dynamic navigation From 367fa92b3d158a55d1e9357332e96515e79ce705 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Fri, 6 Mar 2026 17:50:45 -0800 Subject: [PATCH 2/7] Use diff highlighting --- .../custom-components/template-react.md | 111 ++++++------------ .../custom-components/template-typescript.md | 36 +++--- 2 files changed, 57 insertions(+), 90 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md index 4c516846c..fddf2b095 100644 --- a/content/develop/tutorials/custom-components/template-react.md +++ b/content/develop/tutorials/custom-components/template-react.md @@ -566,88 +566,51 @@ Now extend the template to render a dynamic list of items from Python data. This export default MyComponent; ``` -1. In `my_react_counter/frontend/src/index.tsx`, replace the file contents with the following to pass the new props: +1. In `my_react_counter/frontend/src/index.tsx`, make the following changes to pass the new props: - ```typescript - import { - FrontendRenderer, - FrontendRendererArgs, - } from "@streamlit/component-v2-lib"; - import { StrictMode } from "react"; - import { createRoot, Root } from "react-dom/client"; - - import MyComponent, { - MyComponentDataShape, - MyComponentStateShape, - } from "./MyComponent"; - - const reactRoots: WeakMap< - FrontendRendererArgs["parentElement"], - Root - > = new WeakMap(); - - const MyComponentRoot: FrontendRenderer< - MyComponentStateShape, - MyComponentDataShape + ```diff-typescript > = (args) => { - const { data, parentElement, setStateValue, setTriggerValue } = args; - - const rootElement = parentElement.querySelector(".react-root"); - if (!rootElement) { - throw new Error("Unexpected: React root element not found"); - } - - let reactRoot = reactRoots.get(parentElement); - if (!reactRoot) { - reactRoot = createRoot(rootElement); - reactRoots.set(parentElement, reactRoot); - } - - const { name, items } = data; - - reactRoot.render( - - - , - ); - - return () => { - const reactRoot = reactRoots.get(parentElement); - if (reactRoot) { - reactRoot.unmount(); - reactRoots.delete(parentElement); - } - }; - }; - - export default MyComponentRoot; + - const { data, parentElement, setStateValue } = args; + + const { data, parentElement, setStateValue, setTriggerValue } = args; ``` -1. In `my_react_counter/__init__.py`, replace the file contents with the following to pass items and handle the new callbacks: - - ```python - import streamlit as st - - out = st.components.v2.component( - "my-react-counter.my_react_counter", - js="index-*.js", - html='
', - ) + ```diff-typescript + - const { name } = data; + + const { name, items } = data; + + reactRoot.render( + + - + + + , + ); + ``` +1. In `my_react_counter/__init__.py`, make the following changes to pass items and handle the new callbacks: - def my_component(name, items=None, key=None, on_item_clicked=lambda: None): + ```diff-python + -def on_num_clicks_change(): + - pass + - + - + -def my_component(name, key=None): + +def my_component(name, items=None, key=None, on_item_clicked=lambda: None): component_value = out( + - name=name, key=key, - default={"num_clicks": 0, "selected_item": None}, - data={"name": name, "items": items or []}, - on_num_clicks_change=lambda: None, - on_selected_item_change=lambda: None, - on_item_clicked_change=on_item_clicked, + - default={"num_clicks": 0}, + - data={"name": name}, + - on_num_clicks_change=on_num_clicks_change, + + default={"num_clicks": 0, "selected_item": None}, + + data={"name": name, "items": items or []}, + + on_num_clicks_change=lambda: None, + + on_selected_item_change=lambda: None, + + on_item_clicked_change=on_item_clicked, ) return component_value ``` diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md index b4a3c8b3e..0a4af052c 100644 --- a/content/develop/tutorials/custom-components/template-typescript.md +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -364,23 +364,20 @@ The `vite.config.ts` builds your TypeScript into an ES module with a hashed file Now extend the template to add a reset button and a trigger value that fires when the counter is reset. -1. In `my_click_counter/__init__.py`, replace the `html` parameter with the following to add a reset button and a count display: +1. In `my_click_counter/__init__.py`, make the following changes to the `html` parameter to add a reset button and a count display: - ```python - out = st.components.v2.component( - "my-click-counter.my_click_counter", - js="index-*.js", + ```diff-python html="""

Hello, World!

-
- - -
-

+ - + +
+ + + + + +
+ +

""", - ) ``` 1. In `my_click_counter/frontend/src/index.ts`, replace the file contents with the following to handle both buttons: @@ -466,16 +463,23 @@ Now extend the template to add a reset button and a trigger value that fires whe - Added a reset handler that sets the count back to zero and fires a `"was_reset"` trigger. - Added a count display that updates on each click. -1. In `my_click_counter/__init__.py`, replace the wrapper function with the following to handle the new trigger: +1. In `my_click_counter/__init__.py`, make the following changes to the wrapper function to handle the new trigger: - ```python - def my_component(name, key=None, on_reset=lambda: None): + ```diff-python + -def on_num_clicks_change(): + - pass + - + - + -def my_component(name, key=None): + +def my_component(name, key=None, on_reset=lambda: None): component_value = out( + - name=name, key=key, default={"num_clicks": 0}, data={"name": name}, - on_num_clicks_change=lambda: None, - on_was_reset_change=on_reset, + - on_num_clicks_change=on_num_clicks_change, + + on_num_clicks_change=lambda: None, + + on_was_reset_change=on_reset, ) return component_value ``` From f6fa2931e3fefd041b62d7a5694dfcd0f5a3d92e Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Fri, 6 Mar 2026 22:35:14 -0800 Subject: [PATCH 3/7] Update tutorials --- .../custom-components/template-react.md | 140 ++++---- .../custom-components/template-typescript.md | 327 ++++++++++-------- 2 files changed, 247 insertions(+), 220 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md index fddf2b095..f9474c1d4 100644 --- a/content/develop/tutorials/custom-components/template-react.md +++ b/content/develop/tutorials/custom-components/template-react.md @@ -11,18 +11,14 @@ In this tutorial, you'll use the official [component template](https://github.co ## Prerequisites -- The following versions are required: - - ```text - python>=3.10 - node>=24 +- The following packages must be installed in your Python environment: + ```text hideHeader streamlit>=1.51.0 + uv ``` - -- [uv](https://docs.astral.sh/uv/) (recommended Python package manager) -- npm (included with Node.js) -- Familiarity with [React](https://react.dev/) basics (components, hooks, JSX) -- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) +- Node.js 24 or later must be installed. This includes npm, the package manager for JavaScript. +- Familiarity with [React](https://react.dev/) basics (components, hooks, JSX) is recommended. +- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) is recommended. ## Summary @@ -145,6 +141,7 @@ import { FC, ReactElement, useCallback, useState } from "react"; export type MyComponentStateShape = { num_clicks: number; selected_item: string | null; + item_clicked: string | null; }; export type MyComponentDataShape = { @@ -260,31 +257,33 @@ if result.item_clicked: uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2 ``` -1. Follow the interactive prompts. When asked for the framework, choose **React + Typescript**: +1. Follow the interactive prompts. When asked for the framework, select **React + Typescript**: - ``` - author_name [John Smith]: Your Name - author_email [john@example.com]: you@example.com - project_name [Streamlit Component X]: My React Counter - package_name [my-react-counter]: - import_name [my_react_counter]: - description [Streamlit component that allows you to do X]: A React-based counter component - Select open_source_license: + ```shell + [1/8] author_name (John Smith): Your Name + [2/8] author_email (john@example.com): you@example.com + [3/8] project_name (Streamlit Component X): My React Counter + [4/8] package_name (streamlit-component-x): my-react-counter + [5/8] import_name (streamlit_component_x): my_react_counter + [6/8] description (Streamlit component that allows you to do X): A React-based counter component + [7/8] Select open_source_license ... - Select framework: - 1 - React + Typescript - 2 - Pure Typescript - Choose from 1, 2 [1]: 1 + Choose from [1/2/3/4/5/6](1): 1 + [8/8] Select framework + 1 - React + Typescript + 2 - Pure Typescript + Choose from [1/2] (1): 1 ``` This creates a `my-react-counter/` directory with the following structure: ``` my-react-counter/ - ├── pyproject.toml + ├── example.py ├── LICENSE + ├── MANIFEST.in + ├── pyproject.toml ├── README.md - ├── example.py └── my_react_counter/ ├── __init__.py ├── pyproject.toml @@ -294,26 +293,20 @@ if result.item_clicked: ├── vite.config.ts └── src/ ├── index.tsx - └── MyComponent.tsx + ├── MyComponent.tsx + └── vite-env.d.ts ``` Notice the React template has two frontend source files instead of one: `index.tsx` handles integration with Streamlit's lifecycle, and `MyComponent.tsx` contains the React component. ## Run the template -You need two terminals running in parallel for development. +You need two terminals running in parallel for development. The following steps use `uv run` to run commands inside the project's virtual environment. If a `.venv` doesn't exist yet, `uv run` creates one automatically. -1. In the first terminal, navigate into your new project directory and install the Python package in editable mode: +1. In the first terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: ```bash - cd my-react-counter - uv pip install -e . - ``` - -1. In the same terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: - - ```bash - cd my_react_counter/frontend + cd my-react-counter/my_react_counter/frontend npm install npm run dev ``` @@ -322,7 +315,7 @@ You need two terminals running in parallel for development. ```bash cd my-react-counter - streamlit run example.py + uv run streamlit run example.py ``` 1. View your running app. @@ -482,6 +475,7 @@ Now extend the template to render a dynamic list of items from Python data. This export type MyComponentStateShape = { num_clicks: number; selected_item: string | null; + item_clicked: string | null; }; export type MyComponentDataShape = { @@ -569,26 +563,32 @@ Now extend the template to render a dynamic list of items from Python data. This 1. In `my_react_counter/frontend/src/index.tsx`, make the following changes to pass the new props: ```diff-typescript - > = (args) => { - - const { data, parentElement, setStateValue } = args; - + const { data, parentElement, setStateValue, setTriggerValue } = args; + => = (args) => { + - const { data, parentElement, setStateValue } = args; + + const { data, parentElement, setStateValue, setTriggerValue } = args; ``` + + + The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. + + + ```diff-typescript - - const { name } = data; - + const { name, items } = data; - - reactRoot.render( - - - - + - , - ); + - const { name } = data; + + const { name, items } = data; + = + = reactRoot.render( + = + - + + + = , + = ); ``` 1. In `my_react_counter/__init__.py`, make the following changes to pass items and handle the new callbacks: @@ -600,21 +600,23 @@ Now extend the template to render a dynamic list of items from Python data. This - -def my_component(name, key=None): +def my_component(name, items=None, key=None, on_item_clicked=lambda: None): - component_value = out( - - name=name, - key=key, - - default={"num_clicks": 0}, - - data={"name": name}, - - on_num_clicks_change=on_num_clicks_change, - + default={"num_clicks": 0, "selected_item": None}, - + data={"name": name, "items": items or []}, - + on_num_clicks_change=lambda: None, - + on_selected_item_change=lambda: None, - + on_item_clicked_change=on_item_clicked, - ) - return component_value + = component_value = out( + - name=name, + = key=key, + - default={"num_clicks": 0}, + - data={"name": name}, + - on_num_clicks_change=on_num_clicks_change, + + default={"num_clicks": 0, "selected_item": None}, + + data={"name": name, "items": items or []}, + + on_num_clicks_change=lambda: None, + + on_selected_item_change=lambda: None, + + on_item_clicked_change=on_item_clicked, + = ) + = return component_value ``` + The wrapper now accepts `items` and an `on_item_clicked` callback (defaulting to `lambda: None`). Inside, `on_num_clicks_change` and `on_selected_item_change` use inline lambdas since nothing needs to happen for those events. `on_item_clicked_change` passes through the caller's callback so the app can react when an item is clicked. + 1. To exercise the new list feature, replace the contents of `example.py` with the following: ```python @@ -642,7 +644,9 @@ Now extend the template to render a dynamic list of items from Python data. This When you're ready to share your component, create a production build. -1. In a terminal, navigate to the frontend directory and build the frontend: +1. Stop the `npm run dev` watcher and the `streamlit run` process by pressing `Ctrl+C` in each terminal. + +1. In either terminal, navigate to the frontend directory and build the frontend: ```bash cd my-react-counter/my_react_counter/frontend diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md index 0a4af052c..c47da6ca1 100644 --- a/content/develop/tutorials/custom-components/template-typescript.md +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -11,17 +11,13 @@ In this tutorial, you'll use the official [component template](https://github.co ## Prerequisites -- The following versions are required: - - ```text - python>=3.10 - node>=24 +- The following packages must be installed in your Python environment: + ```text hideHeader streamlit>=1.51.0 + uv ``` - -- [uv](https://docs.astral.sh/uv/) (recommended Python package manager) -- npm (included with Node.js) -- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) +- Node.js 24 or later must be installed. This includes npm, the package manager for JavaScript. +- Familiarity with [inline custom components](/develop/concepts/custom-components/components-v2/examples) is recommended. ## Summary @@ -57,20 +53,23 @@ out = st.components.v2.component( "my-click-counter.my_click_counter", js="index-*.js", html=""" -
-

Hello, World!

-
- - +
+ +

+
+ + +
+

+
-

-
""", ) -def my_component(name, key=None, on_reset=lambda: None): +def my_click_counter(name, key=None, on_reset=lambda: None): component_value = out( + name=name, key=key, default={"num_clicks": 0}, data={"name": name}, @@ -90,6 +89,7 @@ import { export type FrontendState = { num_clicks: number; + was_reset: boolean; }; export type ComponentData = { @@ -160,14 +160,14 @@ export default MyComponent; ```python import streamlit as st -from my_click_counter import my_component +from my_click_counter import my_click_counter st.title("My Click Counter") def handle_reset(): st.toast("Counter was reset!") -result = my_component("Streamlit", key="counter", on_reset=handle_reset) +result = my_click_counter("Streamlit", key="counter", on_reset=handle_reset) st.write(f"Click count: {result.num_clicks}") if result.was_reset: @@ -184,31 +184,33 @@ if result.was_reset: uvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2 ``` -1. Follow the interactive prompts. When asked for the framework, choose **Pure Typescript**: +1. Follow the interactive prompts. When asked for the framework, select **Pure Typescript**: - ``` - author_name [John Smith]: Your Name - author_email [john@example.com]: you@example.com - project_name [Streamlit Component X]: My Click Counter - package_name [my-click-counter]: - import_name [my_click_counter]: - description [Streamlit component that allows you to do X]: A click counter component - Select open_source_license: + ```shell + [1/8] author_name (John Smith): Your Name + [2/8] author_email (john@example.com): you@example.com + [3/8] project_name (Streamlit Component X): My Click Counter + [4/8] package_name (streamlit-component-x): my-click-counter + [5/8] import_name (streamlit_component_x): my_click_counter + [6/8] description (Streamlit component that allows you to do X): A click counter component + [7/8] Select open_source_license ... - Select framework: - 1 - React + Typescript - 2 - Pure Typescript - Choose from 1, 2 [1]: 2 + Choose from [1/2/3/4/5/6](1): 1 + [8/8] Select framework + 1 - React + Typescript + 2 - Pure Typescript + Choose from [1/2] (1): 2 ``` This creates a `my-click-counter/` directory with the following structure: ``` my-click-counter/ - ├── pyproject.toml + ├── example.py ├── LICENSE + ├── MANIFEST.in + ├── pyproject.toml ├── README.md - ├── example.py └── my_click_counter/ ├── __init__.py ├── pyproject.toml @@ -217,24 +219,18 @@ if result.was_reset: ├── tsconfig.json ├── vite.config.ts └── src/ - └── index.ts + ├── index.ts + └── vite-env.d.ts ``` ## Run the template -You need two terminals running in parallel for development. - -1. In the first terminal, navigate into your new project directory and install the Python package in editable mode: - - ```bash - cd my-click-counter - uv pip install -e . - ``` +You need two terminals running in parallel for development. The following steps use `uv run` to run commands inside the project's virtual environment. If a `.venv` doesn't exist yet, `uv run` creates one automatically. -1. In the same terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: +1. In the first terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher: ```bash - cd my_click_counter/frontend + cd my-click-counter/my_click_counter/frontend npm install npm run dev ``` @@ -243,12 +239,12 @@ You need two terminals running in parallel for development. ```bash cd my-click-counter - streamlit run example.py + uv run streamlit run example.py ``` 1. View your running app. - You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. + You should two instances of the component running in your browser. Each instance has a "Hello, World!" heading and a "Click Me!" button. The second instance passes a different name to the component from an `st.text_input` widget above it. ## Understand the generated code @@ -263,10 +259,12 @@ Now that the component is running, walk through each file to understand how it w "my-click-counter.my_click_counter", js="index-*.js", html=""" -
-

Hello, World!

- -
+
+ +

+ +
+
""", ) @@ -275,7 +273,7 @@ Now that the component is running, walk through each file to understand how it w pass - def my_component(name, key=None): + def my_click_counter(name, key=None): component_value = out( name=name, key=key, @@ -283,11 +281,12 @@ Now that the component is running, walk through each file to understand how it w data={"name": name}, on_num_clicks_change=on_num_clicks_change, ) + return component_value ``` This file does two things: - - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) that matches the metadata in the `pyproject.toml` files. The `js` parameter uses a glob pattern to match the hashed build output, and `html` provides the initial markup. + - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) where `` matches the `name` field in the project-level `pyproject.toml` and `` matches the `name` field in the component-level `pyproject.toml`. The other two arguments point to the frontend assets: `js` is a glob pattern that matches the JavaScript bundle produced by Vite. `html` provides the initial markup that's rendered before the JavaScript loads. - **Defines a wrapper function** (`my_component`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount). @@ -327,7 +326,7 @@ Now that the component is running, walk through each file to understand how it w heading.textContent = `Hello, ${data.name}!`; } - const button = rootElement.querySelector("button"); + const button = rootElement.querySelector("button"); if (!button) { throw new Error("Unexpected: button element not found"); } @@ -362,105 +361,125 @@ The `vite.config.ts` builds your TypeScript into an ES module with a hashed file ## Modify the component -Now extend the template to add a reset button and a trigger value that fires when the counter is reset. +You can extend the template to add a reset button and a trigger value that fires when the counter is reset. 1. In `my_click_counter/__init__.py`, make the following changes to the `html` parameter to add a reset button and a count display: ```diff-python - html=""" -
-

Hello, World!

- - - +
- + - + - +
- +

-
- """, + = html=""" + =
+ = + =

+ - + +
+ + + + + +
+ +

+ =
+ =
+ = """, ``` -1. In `my_click_counter/frontend/src/index.ts`, replace the file contents with the following to handle both buttons: - - ```typescript - import { - FrontendRenderer, - FrontendRendererArgs, - } from "@streamlit/component-v2-lib"; - - export type FrontendState = { - num_clicks: number; - }; - - export type ComponentData = { - name: string; - }; + - const instances: WeakMap< - FrontendRendererArgs["parentElement"], - { numClicks: number } - > = new WeakMap(); + The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. - const MyComponent: FrontendRenderer = ( - args, - ) => { - const { parentElement, data, setStateValue, setTriggerValue } = args; + - const rootElement = parentElement.querySelector(".component-root"); - if (!rootElement) { - throw new Error("Unexpected: root element not found"); - } - - const heading = rootElement.querySelector("h1"); - if (heading) { - heading.textContent = `Hello, ${data.name}!`; - } - - const incrementBtn = - rootElement.querySelector("#increment"); - const resetBtn = rootElement.querySelector("#reset"); - const countDisplay = rootElement.querySelector("#count"); - - if (!incrementBtn || !resetBtn || !countDisplay) { - throw new Error("Unexpected: required elements not found"); - } - - const currentCount = instances.get(parentElement)?.numClicks || 0; - countDisplay.textContent = `Clicks: ${currentCount}`; - - const handleIncrement = () => { - const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; - instances.set(parentElement, { numClicks }); - countDisplay.textContent = `Clicks: ${numClicks}`; - setStateValue("num_clicks", numClicks); - }; - - const handleReset = () => { - instances.set(parentElement, { numClicks: 0 }); - countDisplay.textContent = `Clicks: 0`; - setStateValue("num_clicks", 0); - setTriggerValue("was_reset", true); - }; - - if (!instances.has(parentElement)) { - incrementBtn.addEventListener("click", handleIncrement); - resetBtn.addEventListener("click", handleReset); - instances.set(parentElement, { numClicks: 0 }); - } - - return () => { - incrementBtn.removeEventListener("click", handleIncrement); - resetBtn.removeEventListener("click", handleReset); - instances.delete(parentElement); - }; - }; +1. In `my_click_counter/frontend/src/index.ts`, replace the file contents with the following to handle both buttons: - export default MyComponent; + ```diff-typescript + =import { + = FrontendRenderer, + = FrontendRendererArgs, + =} from "@streamlit/component-v2-lib"; + = + =export type FrontendState = { + = num_clicks: number; + + was_reset: boolean; + =}; + = + =export type ComponentData = { + = name: string; + =}; + = + =const instances: WeakMap< + = FrontendRendererArgs["parentElement"], + = { numClicks: number } + => = new WeakMap(); + = + =const MyComponent: FrontendRenderer = ( + = args, + =) => { + - const { parentElement, data, setStateValue } = args; + + const { parentElement, data, setStateValue, setTriggerValue } = args; + = + = const rootElement = parentElement.querySelector(".component-root"); + = if (!rootElement) { + = throw new Error("Unexpected: root element not found"); + = } + = + = const heading = rootElement.querySelector("h1"); + = if (heading) { + = heading.textContent = `Hello, ${data.name}!`; + = } + = + - const button = rootElement.querySelector("button"); + - if (!button) { + - throw new Error("Unexpected: button element not found"); + - } + + const incrementBtn = + + rootElement.querySelector("#increment"); + + const resetBtn = rootElement.querySelector("#reset"); + + const countDisplay = rootElement.querySelector("#count"); + + + + if (!incrementBtn || !resetBtn || !countDisplay) { + + throw new Error("Unexpected: required elements not found"); + + } + + + + const currentCount = instances.get(parentElement)?.numClicks || 0; + + countDisplay.textContent = `Clicks: ${currentCount}`; + - + - const handleClick = () => { + + const handleIncrement = () => { + = const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; + = instances.set(parentElement, { numClicks }); + + countDisplay.textContent = `Clicks: ${numClicks}`; + = setStateValue("num_clicks", numClicks); + = }; + + + + const handleReset = () => { + + instances.set(parentElement, { numClicks: 0 }); + + countDisplay.textContent = `Clicks: 0`; + + setStateValue("num_clicks", 0); + + setTriggerValue("was_reset", true); + + }; + = + = if (!instances.has(parentElement)) { + - button.addEventListener("click", handleClick); + + incrementBtn.addEventListener("click", handleIncrement); + + resetBtn.addEventListener("click", handleReset); + = instances.set(parentElement, { numClicks: 0 }); + = } + = + = return () => { + - button.removeEventListener("click", handleClick); + + incrementBtn.removeEventListener("click", handleIncrement); + + resetBtn.removeEventListener("click", handleReset); + = instances.delete(parentElement); + = }; + =}; + = + =export default MyComponent; ``` The key changes are: + - Added `was_reset` to the `FrontendState` type. - Added `setTriggerValue` to the destructured args. Unlike `setStateValue`, trigger values are transient and reset to `None` after each rerun. - - Added a reset handler that sets the count back to zero and fires a `"was_reset"` trigger. + - Renamed the button to `incrementBtn` and the click handler to `handleIncrement`. + - Named the new button `resetBtn`. + - Added a reset handler, `handleReset`, that sets the count back to zero and fires a `"was_reset"` trigger. - Added a count display that updates on each click. 1. In `my_click_counter/__init__.py`, make the following changes to the wrapper function to handle the new trigger: @@ -470,34 +489,36 @@ Now extend the template to add a reset button and a trigger value that fires whe - pass - - - -def my_component(name, key=None): - +def my_component(name, key=None, on_reset=lambda: None): - component_value = out( - - name=name, - key=key, - default={"num_clicks": 0}, - data={"name": name}, + -def my_click_counter(name, key=None): + +def my_click_counter(name, key=None, on_reset=lambda: None): + = component_value = out( + = name=name, + = key=key, + = default={"num_clicks": 0}, + = data={"name": name}, - on_num_clicks_change=on_num_clicks_change, + on_num_clicks_change=lambda: None, + on_was_reset_change=on_reset, - ) - return component_value + = ) + = return component_value ``` + The wrapper now accepts an `on_reset` callback that defaults to `lambda: None`. Inside, `on_num_clicks_change` uses an inline lambda since nothing needs to happen when the count changes. `on_was_reset_change` passes through the caller's `on_reset` callback so the app can react when the counter is reset. + 1. If `npm run dev` is still running, the frontend rebuilds automatically. Refresh your Streamlit app to see the changes. -1. To exercise the new functionality, replace the contents of `example.py` with the following: +1. To try the new functionality in a clean example, replace the contents of `example.py` with the following code: ```python import streamlit as st - from my_click_counter import my_component + from my_click_counter import my_click_counter st.title("My Click Counter") def handle_reset(): st.toast("Counter was reset!") - result = my_component("Streamlit", key="counter", on_reset=handle_reset) + result = my_click_counter("Streamlit", key="counter", on_reset=handle_reset) st.write(f"Click count: {result.num_clicks}") if result.was_reset: @@ -510,7 +531,9 @@ Now extend the template to add a reset button and a trigger value that fires whe When you're ready to share your component, create a production build. -1. In a terminal, navigate to the frontend directory and build the frontend: +1. Stop the `npm run dev` watcher and the `streamlit run` process by pressing `Ctrl+C` in each terminal. + +1. In either terminal, navigate to the frontend directory and build the frontend: ```bash cd my-click-counter/my_click_counter/frontend From a1869412d14ada0b50387f662733a63cd2115c93 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sat, 7 Mar 2026 13:25:35 -0800 Subject: [PATCH 4/7] Update react tutorial --- .../custom-components/template-react.md | 319 +++++++++++------- .../custom-components/template-typescript.md | 6 +- 2 files changed, 193 insertions(+), 132 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md index f9474c1d4..2b1dcc382 100644 --- a/content/develop/tutorials/custom-components/template-react.md +++ b/content/develop/tutorials/custom-components/template-react.md @@ -180,38 +180,23 @@ const MyComponent: FC = ({ ); return ( -
+

Hello, {name}!

- {items && items.length > 0 && ( -
    +
      {items.map((item) => (
    • onItemSelected(item)} style={{ - padding: "0.5rem 1rem", - margin: "0.25rem 0", + cursor: "pointer", background: selectedItem === item ? "var(--st-primary-color)" : "var(--st-secondary-background-color)", - color: selectedItem === item ? "white" : "inherit", - borderRadius: "var(--st-base-radius)", - cursor: "pointer", - border: `1px solid var(--st-border-color)`, }} > {item} @@ -320,11 +305,44 @@ You need two terminals running in parallel for development. The following steps 1. View your running app. - You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. + You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. An `st.text_input` lets you specify a name which is passed to a second instance of the component. ## Understand the generated code -The Python side (`__init__.py` and `pyproject.toml` files) is identical to the Pure TypeScript template. See [Package-based components](/develop/concepts/custom-components/components-v2/package-based#understanding-the-project-structure) for details on those files. This section focuses on the React-specific frontend code. +Now that the component is running, walk through each file to understand how it works. + +1. Open `my_click_counter/__init__.py`: + + ```python + import streamlit as st + + out = st.components.v2.component( + "my-react-counter.my_react_counter", + js="index-*.js", + html='
      ', + ) + + + def on_num_clicks_change(): + pass + + + def my_react_counter(name, key=None): + component_value = out( + name=name, + key=key, + default={"num_clicks": 0}, + data={"name": name}, + on_num_clicks_change=on_num_clicks_change, + ) + + return component_value + ``` + + This file does two things: + - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) where `` matches the `name` field in the project-level `pyproject.toml` and `` matches the `name` field in the component-level `pyproject.toml`. The other two arguments point to the frontend assets: `js` is a glob pattern that matches the JavaScript bundle produced by Vite. `html` provides the root `
      ` that React mounts into. + + - **Defines a wrapper function** (`my_react_counter`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount). 1. Open `my_react_counter/frontend/src/index.tsx`: @@ -341,10 +359,8 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P MyComponentStateShape, } from "./MyComponent"; - const reactRoots: WeakMap< - FrontendRendererArgs["parentElement"], - Root - > = new WeakMap(); + const reactRoots: WeakMap = + new WeakMap(); const MyComponentRoot: FrontendRenderer< MyComponentStateShape, @@ -353,6 +369,7 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P const { data, parentElement, setStateValue } = args; const rootElement = parentElement.querySelector(".react-root"); + if (!rootElement) { throw new Error("Unexpected: React root element not found"); } @@ -367,12 +384,13 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P reactRoot.render( - + , ); return () => { const reactRoot = reactRoots.get(parentElement); + if (reactRoot) { reactRoot.unmount(); reactRoots.delete(parentElement); @@ -383,9 +401,9 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P export default MyComponentRoot; ``` - This file is the bridge between Streamlit's component lifecycle and React. The pattern is different from a typical React app: - - **React root management**: Streamlit calls your `FrontendRenderer` function on every re-render (whenever `data` changes). You can't create a new React root each time; instead, the `WeakMap` stores one root per component instance (keyed by `parentElement`). On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. - - **Passing props**: The bridge extracts `data` and `setStateValue` from Streamlit's args and passes them as React props to `MyComponent`. This is where you decide which Streamlit args your React component needs. + This file bridges Streamlit's component lifecycle and React. Because Streamlit calls your `FrontendRenderer` function on every re-render (whenever `data` changes), the pattern is different from a typical React app: + - **React root management**: You can't create a new React root each time Streamlit calls your function. Instead, the `WeakMap` stores one root per component instance, keyed by `parentElement`. On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. + - **Passing props**: `MyComponentRoot` extracts `data` and `setStateValue` from Streamlit's args and passes them as React props to `MyComponent`. This is where you decide which Streamlit args your React component needs. - **Cleanup**: The returned function unmounts the React root when Streamlit removes the component from the page. 1. Open `my_react_counter/frontend/src/MyComponent.tsx`: @@ -426,26 +444,39 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P const colorToUse = isFocused ? "var(--st-primary-color)" : "var(--st-gray-color)"; + + const borderStyling = `1px solid ${colorToUse}`; + return { - border: `1px solid ${colorToUse}`, - outline: `1px solid ${colorToUse}`, + border: borderStyling, + outline: borderStyling, }; }, [isFocused]); const onClicked = useCallback((): void => { const newNumClicks = numClicks + 1; setNumClicks(newNumClicks); + setStateValue("num_clicks", newNumClicks); }, [numClicks, setStateValue]); + const onFocus = useCallback((): void => { + setIsFocused(true); + }, []); + + const onBlur = useCallback((): void => { + setIsFocused(false); + }, []); + return (

      Hello, {name}!

      @@ -456,7 +487,7 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P export default MyComponent; ``` - This is a standard React functional component. Note the following: + This is a standard React functional component: - **Type-safe props**: `MyComponentProps` is constructed from `FrontendRendererArgs` using TypeScript's `Pick` utility type. This ensures the `setStateValue` prop is correctly typed for the component's state shape. - **React state management**: Local UI state (like `isFocused`) is managed with React's `useState` hook. This state is purely for the frontend and doesn't need to go back to Python. - **Communicating with Python**: When the button is clicked, `setStateValue("num_clicks", newNumClicks)` sends the count back to Streamlit. This triggers a Python rerun, just like in non-React components. @@ -466,98 +497,134 @@ The Python side (`__init__.py` and `pyproject.toml` files) is identical to the P Now extend the template to render a dynamic list of items from Python data. This showcases something React does well: declaratively rendering lists with state. -1. In `my_react_counter/frontend/src/MyComponent.tsx`, replace the file contents with the following: - - ```typescript - import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; - import { FC, ReactElement, useCallback, useState } from "react"; - - export type MyComponentStateShape = { - num_clicks: number; - selected_item: string | null; - item_clicked: string | null; - }; - - export type MyComponentDataShape = { - name: string; - items: string[]; - }; - - export type MyComponentProps = Pick< - FrontendRendererArgs, - "setStateValue" | "setTriggerValue" - > & - MyComponentDataShape; +1. In `my_react_counter/frontend/src/MyComponent.tsx`, edit the file to add a list of items: - const MyComponent: FC = ({ - name, - items, - setStateValue, - setTriggerValue, - }): ReactElement => { - const [numClicks, setNumClicks] = useState(0); - const [selectedItem, setSelectedItem] = useState(null); - - const onClicked = useCallback((): void => { - const newNumClicks = numClicks + 1; - setNumClicks(newNumClicks); - setStateValue("num_clicks", newNumClicks); - }, [numClicks, setStateValue]); + - const onItemSelected = useCallback( - (item: string): void => { - setSelectedItem(item); - setStateValue("selected_item", item); - setTriggerValue("item_clicked", item); - }, - [setStateValue, setTriggerValue], - ); + The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. - return ( -
      -

      Hello, {name}!

      - - {items && items.length > 0 && ( -
        - {items.map((item) => ( -
      • onItemSelected(item)} - style={{ - padding: "0.5rem 1rem", - margin: "0.25rem 0", - background: - selectedItem === item - ? "var(--st-primary-color)" - : "var(--st-secondary-background-color)", - color: selectedItem === item ? "white" : "inherit", - borderRadius: "var(--st-base-radius)", - cursor: "pointer", - border: `1px solid var(--st-border-color)`, - }} - > - {item} -
      • - ))} -
      - )} -
      - ); - }; +
      - export default MyComponent; + ```diff-typescript + =import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; + -import { + - CSSProperties, + - FC, + - ReactElement, + - useCallback, + - useMemo, + - useState, + -} from "react"; + +import { FC, ReactElement, useCallback, useState } from "react"; + = + =export type MyComponentStateShape = { + = num_clicks: number; + + selected_item: string | null; + + item_clicked: string | null; + =}; + = + =export type MyComponentDataShape = { + = name: string; + + items: string[]; + =}; + = + =export type MyComponentProps = Pick< + = FrontendRendererArgs, + - "setStateValue" + + "setStateValue" | "setTriggerValue" + => & + = MyComponentDataShape; + = + =const MyComponent: FC = ({ + = name, + + items, + = setStateValue, + + setTriggerValue, + =}): ReactElement => { + - const [isFocused, setIsFocused] = useState(false); + = const [numClicks, setNumClicks] = useState(0); + + const [selectedItem, setSelectedItem] = useState(null); + - + - const style = useMemo(() => { + - const colorToUse = isFocused + - ? "var(--st-primary-color)" + - : "var(--st-gray-color)"; + - + - const borderStyling = `1px solid ${colorToUse}`; + - + - return { + - border: borderStyling, + - outline: borderStyling, + - }; + - }, [isFocused]); + = + = const onClicked = useCallback((): void => { + = const newNumClicks = numClicks + 1; + = setNumClicks(newNumClicks); + = setStateValue("num_clicks", newNumClicks); + = }, [numClicks, setStateValue]); + = + - const onFocus = useCallback((): void => { + - setIsFocused(true); + - }, []); + - + - const onBlur = useCallback((): void => { + - setIsFocused(false); + - }, []); + - + - return ( + - + -

      Hello, {name}!

      + - + -
      + - ); + + const onItemSelected = useCallback( + + (item: string): void => { + + setSelectedItem(item); + + setStateValue("selected_item", item); + + setTriggerValue("item_clicked", item); + + }, + + [setStateValue, setTriggerValue], + + ); + + + + return ( + +
      + +

      Hello, {name}!

      + + + + {items && items.length > 0 && ( + +
        + + {items.map((item) => ( + +
      • onItemSelected(item)} + + style={{ + + cursor: "pointer", + + background: + + selectedItem === item + + ? "var(--st-primary-color)" + + : "var(--st-secondary-background-color)", + + }} + + > + + {item} + +
      • + + ))} + +
      + + )} + +
      + + ); + =}; + = + =export default MyComponent; ``` 1. In `my_react_counter/frontend/src/index.tsx`, make the following changes to pass the new props: @@ -568,12 +635,6 @@ Now extend the template to render a dynamic list of items from Python data. This + const { data, parentElement, setStateValue, setTriggerValue } = args; ``` - - - The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. - - - ```diff-typescript - const { name } = data; + const { name, items } = data; @@ -656,7 +717,7 @@ When you're ready to share your component, create a production build. 1. Navigate to the project root and build the Python wheel: ```bash - cd ../../.. + cd ../.. uv build ``` diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md index c47da6ca1..5ea434976 100644 --- a/content/develop/tutorials/custom-components/template-typescript.md +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -244,7 +244,7 @@ You need two terminals running in parallel for development. The following steps 1. View your running app. - You should two instances of the component running in your browser. Each instance has a "Hello, World!" heading and a "Click Me!" button. The second instance passes a different name to the component from an `st.text_input` widget above it. + You should see a "Hello, World!" heading with a "Click Me!" button. Clicking the button increments a counter that's sent back to Python. An `st.text_input` lets you specify a name which is passed to a second instance of the component. ## Understand the generated code @@ -288,7 +288,7 @@ Now that the component is running, walk through each file to understand how it w This file does two things: - **Registers the component** with `st.components.v2.component()`. The first argument is a qualified name (`"."`) where `` matches the `name` field in the project-level `pyproject.toml` and `` matches the `name` field in the component-level `pyproject.toml`. The other two arguments point to the frontend assets: `js` is a glob pattern that matches the JavaScript bundle produced by Vite. `html` provides the initial markup that's rendered before the JavaScript loads. - - **Defines a wrapper function** (`my_component`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount). + - **Defines a wrapper function** (`my_click_counter`) that provides a clean API. The wrapper calls the raw component with `data`, `default`, and callback parameters. This pattern is optional but recommended. For more about these parameters, see [Component mounting](/develop/concepts/custom-components/components-v2/mount). 1. Open `my_click_counter/frontend/src/index.ts`: @@ -543,7 +543,7 @@ When you're ready to share your component, create a production build. 1. Navigate to the project root and build the Python wheel: ```bash - cd ../../.. + cd ../.. uv build ``` From e098455ef4c1478bce8fa48c8dc8b17a4715b1be Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 8 Mar 2026 10:13:30 -0700 Subject: [PATCH 5/7] Doublecheck result TS tutorial files --- .../custom-components/template-typescript.md | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md index 5ea434976..b3e42cff0 100644 --- a/content/develop/tutorials/custom-components/template-typescript.md +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -27,9 +27,7 @@ Here's a look at what you'll build: -Directory structure: - -```none +```none filename="Directory structure" hideCopyButton my-click-counter/ ├── pyproject.toml ├── example.py @@ -44,9 +42,7 @@ my-click-counter/ └── index.ts ``` -`my_click_counter/__init__.py`: - -```python +```python filename="my_click_counter/__init__.py" import streamlit as st out = st.components.v2.component( @@ -79,9 +75,7 @@ def my_click_counter(name, key=None, on_reset=lambda: None): return component_value ``` -`my_click_counter/frontend/src/index.ts`: - -```typescript +```typescript filename="my_click_counter/frontend/src/index.ts" import { FrontendRenderer, FrontendRendererArgs, @@ -125,7 +119,6 @@ const MyComponent: FrontendRenderer = (args) => { const currentCount = instances.get(parentElement)?.numClicks || 0; countDisplay.textContent = `Clicks: ${currentCount}`; - const handleIncrement = () => { const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1; instances.set(parentElement, { numClicks }); @@ -156,9 +149,7 @@ const MyComponent: FrontendRenderer = (args) => { export default MyComponent; ``` -`example.py`: - -```python +```python filename="example.py" import streamlit as st from my_click_counter import my_click_counter @@ -204,7 +195,7 @@ if result.was_reset: This creates a `my-click-counter/` directory with the following structure: - ``` + ```none hideHeader my-click-counter/ ├── example.py ├── LICENSE From cef42a17a6a4d62d85f791aa3768e20a675ca1e3 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 8 Mar 2026 10:36:28 -0700 Subject: [PATCH 6/7] Double check result React tutorial files --- .../custom-components/template-react.md | 45 +++++++------------ .../custom-components/template-typescript.md | 2 +- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md index 2b1dcc382..5639ef0a9 100644 --- a/content/develop/tutorials/custom-components/template-react.md +++ b/content/develop/tutorials/custom-components/template-react.md @@ -28,9 +28,7 @@ Here's a look at what you'll build: -Directory structure: - -```none +```none filename="Directory structure" hideCopyButton my-react-counter/ ├── pyproject.toml ├── example.py @@ -46,9 +44,7 @@ my-react-counter/ └── MyComponent.tsx ``` -`my_react_counter/__init__.py`: - -```python +```python filename="my_react_counter/__init__.py" import streamlit as st out = st.components.v2.component( @@ -57,8 +53,7 @@ out = st.components.v2.component( html='
      ', ) - -def my_component(name, items=None, key=None, on_item_clicked=lambda: None): +def my_react_counter(name, items=None, key=None, on_item_clicked=lambda: None): component_value = out( key=key, default={"num_clicks": 0, "selected_item": None}, @@ -70,9 +65,7 @@ def my_component(name, items=None, key=None, on_item_clicked=lambda: None): return component_value ``` -`my_react_counter/frontend/src/index.tsx`: - -```typescript +```typescript filename="my_react_counter/frontend/src/index.tsx" import { FrontendRenderer, FrontendRendererArgs, @@ -85,10 +78,8 @@ import MyComponent, { MyComponentStateShape, } from "./MyComponent"; -const reactRoots: WeakMap< - FrontendRendererArgs["parentElement"], - Root -> = new WeakMap(); +const reactRoots: WeakMap = + new WeakMap(); const MyComponentRoot: FrontendRenderer< MyComponentStateShape, @@ -97,6 +88,7 @@ const MyComponentRoot: FrontendRenderer< const { data, parentElement, setStateValue, setTriggerValue } = args; const rootElement = parentElement.querySelector(".react-root"); + if (!rootElement) { throw new Error("Unexpected: React root element not found"); } @@ -122,6 +114,7 @@ const MyComponentRoot: FrontendRenderer< return () => { const reactRoot = reactRoots.get(parentElement); + if (reactRoot) { reactRoot.unmount(); reactRoots.delete(parentElement); @@ -132,9 +125,7 @@ const MyComponentRoot: FrontendRenderer< export default MyComponentRoot; ``` -`my_react_counter/frontend/src/MyComponent.tsx`: - -```typescript +```typescript filename="my_react_counter/frontend/src/MyComponent.tsx" import { FrontendRendererArgs } from "@streamlit/component-v2-lib"; import { FC, ReactElement, useCallback, useState } from "react"; @@ -211,9 +202,7 @@ const MyComponent: FC = ({ export default MyComponent; ``` -`example.py`: - -```python +```python filename="example.py" import streamlit as st from my_react_counter import my_component @@ -262,7 +251,7 @@ if result.item_clicked: This creates a `my-react-counter/` directory with the following structure: - ``` + ```none hideHeader my-react-counter/ ├── example.py ├── LICENSE @@ -311,7 +300,7 @@ You need two terminals running in parallel for development. The following steps Now that the component is running, walk through each file to understand how it works. -1. Open `my_click_counter/__init__.py`: +1. Open `my_react_counter/__init__.py`: ```python import streamlit as st @@ -497,7 +486,7 @@ Now that the component is running, walk through each file to understand how it w Now extend the template to render a dynamic list of items from Python data. This showcases something React does well: declaratively rendering lists with state. -1. In `my_react_counter/frontend/src/MyComponent.tsx`, edit the file to add a list of items: +1. In `my_react_counter/frontend/src/MyComponent.tsx`, make the following changes to add list rendering and item selection: @@ -659,8 +648,8 @@ Now extend the template to render a dynamic list of items from Python data. This - pass - - - -def my_component(name, key=None): - +def my_component(name, items=None, key=None, on_item_clicked=lambda: None): + -def my_react_counter(name, key=None): + +def my_react_counter(name, items=None, key=None, on_item_clicked=lambda: None): = component_value = out( - name=name, = key=key, @@ -682,11 +671,11 @@ Now extend the template to render a dynamic list of items from Python data. This ```python import streamlit as st - from my_react_counter import my_component + from my_react_counter import my_react_counter st.title("My React Counter") - result = my_component( + result = my_react_counter( "Streamlit", items=["Python", "TypeScript", "React", "Vite"], key="counter", diff --git a/content/develop/tutorials/custom-components/template-typescript.md b/content/develop/tutorials/custom-components/template-typescript.md index b3e42cff0..5f957146e 100644 --- a/content/develop/tutorials/custom-components/template-typescript.md +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -378,7 +378,7 @@ You can extend the template to add a reset button and a trigger value that fires -1. In `my_click_counter/frontend/src/index.ts`, replace the file contents with the following to handle both buttons: +1. In `my_click_counter/frontend/src/index.ts`, make the following changes to handle both buttons: ```diff-typescript =import { From 7b3c83186e8de8f05da1d2b8f6452849de8754d3 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Wed, 11 Mar 2026 14:40:59 -0700 Subject: [PATCH 7/7] Clarify react tutorial --- .../develop/tutorials/custom-components/template-react.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/develop/tutorials/custom-components/template-react.md b/content/develop/tutorials/custom-components/template-react.md index 5639ef0a9..eacdfc9a9 100644 --- a/content/develop/tutorials/custom-components/template-react.md +++ b/content/develop/tutorials/custom-components/template-react.md @@ -271,7 +271,7 @@ if result.item_clicked: └── vite-env.d.ts ``` - Notice the React template has two frontend source files instead of one: `index.tsx` handles integration with Streamlit's lifecycle, and `MyComponent.tsx` contains the React component. + Notice the React template has two frontend source files instead of one: `index.tsx` handles integration with Streamlit's lifecycle, and `MyComponent.tsx` contains the React component. This is a convention but not a requirement. You can have a single source file or arbitrarily many source files. ## Run the template @@ -391,7 +391,8 @@ Now that the component is running, walk through each file to understand how it w ``` This file bridges Streamlit's component lifecycle and React. Because Streamlit calls your `FrontendRenderer` function on every re-render (whenever `data` changes), the pattern is different from a typical React app: - - **React root management**: You can't create a new React root each time Streamlit calls your function. Instead, the `WeakMap` stores one root per component instance, keyed by `parentElement`. On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. + - **React root management**: You can't create a new React root each time Streamlit calls your function because that would destroy React state on every update. Instead, the `WeakMap` stores one root per component instance, keyed by `parentElement`. On the first call, it creates the root. On subsequent calls, it re-renders into the existing root. This also means multiple instances of the same component in an app each get their own independent React root with their own state. + - **Module-level scope**: Code outside `MyComponentRoot`, like the `WeakMap` declaration, runs once when the module loads and is shared across all component instances. If you need one-time global setup. like initializing a third-party library, put it at the module level so it's done once rather than repeated per instance or per re-render. - **Passing props**: `MyComponentRoot` extracts `data` and `setStateValue` from Streamlit's args and passes them as React props to `MyComponent`. This is where you decide which Streamlit args your React component needs. - **Cleanup**: The returned function unmounts the React root when Streamlit removes the component from the page.