Create a component with Pure TypeScript
In this tutorial, you'll use the official 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:
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 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:
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
import streamlit as st
out = st.components.v2.component(
"my-click-counter.my_click_counter",
js="index-*.js",
html="""
<div class="component-root">
<span>
<h1></h1>
<div class="buttons">
<button id="increment">Click me!</button>
<button id="reset">Reset</button>
</div>
<p id="count"></p>
</span>
</div>
""",
)
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
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<FrontendState, ComponentData> = (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<HTMLButtonElement>("#increment");
const resetBtn = rootElement.querySelector<HTMLButtonElement>("#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;
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
-
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.
Terminaluvx --from cookiecutter cookiecutter gh:streamlit/component-template --directory cookiecutter/v2 -
Follow the interactive prompts. When asked for the framework, select Pure Typescript:
Terminal[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): 2This creates a
my-click-counter/directory with the following structure: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.
-
In the first terminal, navigate to the frontend directory, install dependencies, and start the dev build watcher:
Terminalcd my-click-counter/my_click_counter/frontend npm install npm run dev -
In a second terminal, navigate to the project root and run the example app:
Terminalcd my-click-counter uv run streamlit run example.py -
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_inputlets 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.
-
Open
my_click_counter/__init__.py:Pythonimport streamlit as st out = st.components.v2.component( "my-click-counter.my_click_counter", js="index-*.js", html=""" <div class="component-root"> <span> <h1></h1> <button>Click me!</button> </span> </div> """, ) 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_valueThis file does two things:
-
Registers the component with
st.components.v2.component(). The first argument is a qualified name ("<package-name>.<component-name>") where<package-name>matches thenamefield in the project-levelpyproject.tomland<component-name>matches thenamefield in the component-levelpyproject.toml. The other two arguments point to the frontend assets:jsis a glob pattern that matches the JavaScript bundle produced by Vite.htmlprovides 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 withdata,default, and callback parameters. This pattern is optional but recommended. For more about these parameters, see Component mounting.
-
-
Open
my_click_counter/frontend/src/index.ts:TypeScriptimport { 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<FrontendState, ComponentData> = ( 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<HTMLButtonElement>("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:
FrontendStateandComponentDatadefine the shape of the component's state and the data it receives from Python. These are used as generic parameters onFrontendRendererfor type safety. - Instance tracking: The
WeakMaptracks 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.
- Type definitions:
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.
Modify the component
You can extend the template to add a reset button and a trigger value that fires when the counter is reset.
-
In
my_click_counter/__init__.py, make the following changes to thehtmlparameter to add a reset button and a count display:Python= html=""" = <div class="component-root"> = <span> = <h1></h1> - <button>Click me!</button> + <div class="buttons"> + <button id="increment">Click me!</button> + <button id="reset">Reset</button> + </div> + <p id="count"></p> = </span> = </div> = """,star Tip
The copy button on the diff code blocks only copy the lines in the final result, not the deleted lines.
-
In
my_click_counter/frontend/src/index.ts, make the following changes to handle both buttons: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<FrontendState, ComponentData> = ( = 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<HTMLButtonElement>("button"); - if (!button) { - throw new Error("Unexpected: button element not found"); - } + const incrementBtn = + rootElement.querySelector<HTMLButtonElement>("#increment"); + const resetBtn = rootElement.querySelector<HTMLButtonElement>("#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_resetto theFrontendStatetype. - Added
setTriggerValueto the destructured args. UnlikesetStateValue, trigger values are transient and reset toNoneafter each rerun. - Renamed the button to
incrementBtnand the click handler tohandleIncrement. - 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.
- Added
-
In
my_click_counter/__init__.py, make the following changes to the wrapper function to handle the new trigger: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_valueThe wrapper now accepts an
on_resetcallback that defaults tolambda: None. Inside,on_num_clicks_changeuses an inline lambda since nothing needs to happen when the count changes.on_was_reset_changepasses through the caller'son_resetcallback so the app can react when the counter is reset. -
If
npm run devis still running, the frontend rebuilds automatically. Refresh your Streamlit app to see the changes. -
To try the new functionality in a clean example, replace the contents of
example.pywith the following code:Pythonimport 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.") -
Save your file and view your running app.
Build for production
When you're ready to share your component, create a production build.
-
Stop the
npm run devwatcher and thestreamlit runprocess by pressingCtrl+Cin each terminal. -
In either terminal, navigate to the frontend directory and build the frontend:
Terminalcd my-click-counter/my_click_counter/frontend npm run build -
Navigate to the project root and build the Python wheel:
Terminalcd ../.. uv buildThis creates a
.whlfile in thedist/directory that you can distribute or upload to PyPI. For publishing instructions, see Publish a Component.
What's next?
- Learn more about the project structure in Package-based components.
- Understand State vs trigger values for interactive components.
- Explore Theming and styling to use Streamlit's CSS custom properties.
- Try the React + TypeScript tutorial if you want to use React.
Still have questions?
Our forums are full of helpful information and Streamlit experts.