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..eacdfc9a9 --- /dev/null +++ b/content/develop/tutorials/custom-components/template-react.md @@ -0,0 +1,721 @@ +--- +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 packages must be installed in your Python environment: + ```text hideHeader + streamlit>=1.51.0 + uv + ``` +- 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 + +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: + + + +```none filename="Directory structure" hideCopyButton +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 +``` + +```python filename="my_react_counter/__init__.py" +import streamlit as st + +out = st.components.v2.component( + "my-react-counter.my_react_counter", + js="index-*.js", + html='
', +) + +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}, + 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 +``` + +```typescript filename="my_react_counter/frontend/src/index.tsx" +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 = + 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; +``` + +```typescript filename="my_react_counter/frontend/src/MyComponent.tsx" +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; + +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={{ + cursor: "pointer", + background: + selectedItem === item + ? "var(--st-primary-color)" + : "var(--st-secondary-background-color)", + }} + > + {item} +
  • + ))} +
+ )} +
+ ); +}; + +export default MyComponent; +``` + +```python filename="example.py" +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, select **React + Typescript**: + + ```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 + ... + 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: + + ```none hideHeader + my-react-counter/ + ├── example.py + ├── LICENSE + ├── MANIFEST.in + ├── pyproject.toml + ├── README.md + └── my_react_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + ├── index.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. This is a convention but not a requirement. You can have a single source file or arbitrarily many source files. + +## Run the template + +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 to the frontend directory, install dependencies, and start the dev build watcher: + + ```bash + cd my-react-counter/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 + 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. An `st.text_input` lets you specify a name which is passed to a second instance of the component. + +## Understand the generated code + +Now that the component is running, walk through each file to understand how it works. + +1. Open `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 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`: + + ```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 = + 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 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 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. + +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)"; + + 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}!

+ +
+ ); + }; + + export default MyComponent; + ``` + + 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. + - **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`, make the following changes to add list rendering and item selection: + + + + The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. + + + + ```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: + + ```diff-typescript + => = (args) => { + - const { data, parentElement, setStateValue } = args; + + const { data, parentElement, setStateValue, setTriggerValue } = args; + ``` + + ```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: + + ```diff-python + -def on_num_clicks_change(): + - pass + - + - + -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, + - 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 + import streamlit as st + from my_react_counter import my_react_counter + + st.title("My React Counter") + + result = my_react_counter( + "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. 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 + 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..5f957146e --- /dev/null +++ b/content/develop/tutorials/custom-components/template-typescript.md @@ -0,0 +1,548 @@ +--- +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 packages must be installed in your Python environment: + ```text hideHeader + streamlit>=1.51.0 + uv + ``` +- 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 + +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: + + + +```none filename="Directory structure" hideCopyButton +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 +``` + +```python filename="my_click_counter/__init__.py" +import streamlit as st + +out = st.components.v2.component( + "my-click-counter.my_click_counter", + js="index-*.js", + html=""" +
+ +

+
+ + +
+

+
+
+ """, +) + + +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=lambda: None, + on_was_reset_change=on_reset, + ) + return component_value +``` + +```typescript filename="my_click_counter/frontend/src/index.ts" +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, 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; +``` + +```python filename="example.py" +import streamlit as st +from my_click_counter import my_click_counter + +st.title("My Click Counter") + +def handle_reset(): + st.toast("Counter was reset!") + +result = my_click_counter("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, select **Pure Typescript**: + + ```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 + ... + 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: + + ```none hideHeader + my-click-counter/ + ├── example.py + ├── LICENSE + ├── MANIFEST.in + ├── pyproject.toml + ├── README.md + └── my_click_counter/ + ├── __init__.py + ├── pyproject.toml + └── frontend/ + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── src/ + ├── index.ts + └── vite-env.d.ts + ``` + +## Run the template + +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 to the frontend directory, install dependencies, and start the dev build watcher: + + ```bash + cd my-click-counter/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 + 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. An `st.text_input` lets you specify a name which is passed to a second instance of the component. + +## 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=""" +
+ +

+ +
+
+ """, + ) + + + def on_num_clicks_change(): + pass + + + def my_click_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 initial markup that's rendered before the JavaScript loads. + + - **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`: + + ```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 + +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=""" + =
+ = + =

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

+ =
+ =
+ = """, + ``` + + + + The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines. + + + +1. In `my_click_counter/frontend/src/index.ts`, make the following changes to handle both buttons: + + ```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. + - 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: + + ```diff-python + -def on_num_clicks_change(): + - pass + - + - + -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 + ``` + + 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 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_click_counter + + st.title("My Click Counter") + + def handle_reset(): + st.toast("Counter was reset!") + + result = my_click_counter("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. 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 + 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