diff --git a/src/.vitepress/config/Sidebar.ts b/src/.vitepress/config/Sidebar.ts index 4451fcc..fe6bca5 100644 --- a/src/.vitepress/config/Sidebar.ts +++ b/src/.vitepress/config/Sidebar.ts @@ -67,6 +67,11 @@ export const SIDEBAR: ThemeConfig["sidebar"] = { "Reactive Arrays", "/frameworks/react/tutorial/4-reactive-arrays.md" ), + item( + "Custom Reactive Objects", + "/frameworks/react/tutorial/6-custom-reactive-objects.md" + ), + item("Resources", "/frameworks/react/tutorial/7-resources.md"), ]), group("Tutorial Bonus", [ item( diff --git a/src/frameworks/react/$snippets/tutorial-7-resources-reactive-usage.tsx b/src/frameworks/react/$snippets/tutorial-7-resources-reactive-usage.tsx new file mode 100644 index 0000000..a876f50 --- /dev/null +++ b/src/frameworks/react/$snippets/tutorial-7-resources-reactive-usage.tsx @@ -0,0 +1,41 @@ +import { Component } from "@starbeam/react"; +import { Cell, Resource } from "@starbeam/universal"; +import "./Counter.css"; + +// #region usage +export function Counter() { + return Component(({ use }) => { + // #highlight:next + const date = use(Clock); + + // #highlight:start + function display() { + const formatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + return formatter.format(date.current); + } + // #highlight:end + + // #highlight:next + return () =>
{display()}
; + }); +} +// #endregion + +// #region clock +export const Clock = Resource(({ on }) => { + const now = Cell(new Date()); + + const interval = setInterval(() => { + now.set(new Date()); + }); + + on.cleanup(() => clearInterval(interval)); + + return now; +}); +// #endregion diff --git a/src/frameworks/react/$snippets/tutorial-7-resources.tsx b/src/frameworks/react/$snippets/tutorial-7-resources.tsx new file mode 100644 index 0000000..cd3688e --- /dev/null +++ b/src/frameworks/react/$snippets/tutorial-7-resources.tsx @@ -0,0 +1,36 @@ +import { Component } from "@starbeam/react"; +import { Cell, Resource } from "@starbeam/universal"; +import "./Counter.css"; + +// #region usage +export function Counter() { + return Component(({ use }) => { + const date = use(Clock); + + return () =>
{date.current?.display}
; + }); +} +// #endregion + +// #region clock +export const Clock = Resource(({ on }) => { + const now = Cell(new Date()); + const interval = setInterval(() => { + now.set(new Date()); + }); + + on.cleanup(() => clearInterval(interval)); + + return { + get display() { + const formatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + return formatter.format(now.current); + }, + }; +}); +// #endregion diff --git a/src/frameworks/react/demos/tutorial-7/config.ts b/src/frameworks/react/demos/tutorial-7/config.ts new file mode 100644 index 0000000..645ef21 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/config.ts @@ -0,0 +1,16 @@ +export const files = import.meta.glob(["./index.html", "./src/**"], { + eager: true, + as: "raw", +}); + +export const dependencies = [ + "react", + "react-dom", + "@starbeam/react", + "@starbeam/universal", + "@starbeam/timeline", +]; + +export const jsx = "react"; + +export const activeFile = "src/components/Sum.tsx"; diff --git a/src/frameworks/react/demos/tutorial-7/index.html b/src/frameworks/react/demos/tutorial-7/index.html new file mode 100644 index 0000000..cba0ee6 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/index.html @@ -0,0 +1,10 @@ + + + + + + + +
+ + diff --git a/src/frameworks/react/demos/tutorial-7/src/App.css b/src/frameworks/react/demos/tutorial-7/src/App.css new file mode 100644 index 0000000..1a5b56c --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/App.css @@ -0,0 +1,64 @@ +#root { + max-width: calc(100vw - 6rem); + margin: 0 auto; + padding: 2rem; +} + +h1, +div.card { + text-align: center; +} + +div.card { + display: grid; + box-shadow: 0px 2px 1px -1px rgb(0 0 0 / 20%), + 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%); + border-radius: 4px; + width: 15rem; +} + +div.card > div.buttons { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 1rem; +} + +div.card > button { + width: 45%; + justify-self: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/frameworks/react/demos/tutorial-7/src/App.tsx b/src/frameworks/react/demos/tutorial-7/src/App.tsx new file mode 100644 index 0000000..68ab017 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/App.tsx @@ -0,0 +1,14 @@ +import "./App.css"; +import { Counter } from "./components/Counter"; + +export function App() { + return ( + <> +
+ +
+ + ); +} + +export default App; diff --git a/src/frameworks/react/demos/tutorial-7/src/components/Counter.css b/src/frameworks/react/demos/tutorial-7/src/components/Counter.css new file mode 100644 index 0000000..64ba052 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/components/Counter.css @@ -0,0 +1,13 @@ +pre span:nth-child(1), +h3.count1 { + color: hsl(0, 70%, 50%); +} + +pre span:nth-child(2), +h3.count2 { + color: hsl(100, 50%, 40%); +} + +pre span:nth-child(3) { + color: hsl(200, 70%, 50%); +} diff --git a/src/frameworks/react/demos/tutorial-7/src/components/Counter.tsx b/src/frameworks/react/demos/tutorial-7/src/components/Counter.tsx new file mode 100644 index 0000000..b2c69b1 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/components/Counter.tsx @@ -0,0 +1,11 @@ +import { Component } from "@starbeam/react"; +import { Clock } from "../lib/clock"; +import "./Counter.css"; + +export function Counter(): JSX.Element { + return Component(({ use }) => { + const date = use(Clock); + + return () =>
{date.current?.display}
; + }); +} diff --git a/src/frameworks/react/demos/tutorial-7/src/index.css b/src/frameworks/react/demos/tutorial-7/src/index.css new file mode 100644 index 0000000..d53b2a3 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/index.css @@ -0,0 +1,73 @@ +@import url(https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap); +@import url(https://fonts.googleapis.com/css2?family=Alegreya&family=Alegreya+Sans&family=Merriweather&family=Merriweather+Sans&family=Nunito+Sans&family=Quattrocento&family=Quattrocento+Sans&family=Roboto&family=Roboto+Mono&family=Roboto+Slab&display=swap); + +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: grid; + place-items: center; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + border-color: #c6c6c6; + } +} diff --git a/src/frameworks/react/demos/tutorial-7/src/index.ts b/src/frameworks/react/demos/tutorial-7/src/index.ts new file mode 100644 index 0000000..1b46615 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/index.ts @@ -0,0 +1,9 @@ +import React, { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; +import "./prism.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + React.createElement(StrictMode, {}, React.createElement(App)) +); diff --git a/src/frameworks/react/demos/tutorial-7/src/lib/clock.ts b/src/frameworks/react/demos/tutorial-7/src/lib/clock.ts new file mode 100644 index 0000000..39c17aa --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/lib/clock.ts @@ -0,0 +1,24 @@ +import { Cell, Resource } from "@starbeam/universal"; + +// #region clock +export const Clock = Resource(({ on }) => { + const now = Cell(new Date()); + const interval = setInterval(() => { + now.set(new Date()); + }); + + on.cleanup(() => clearInterval(interval)); + + return { + get display() { + const formatter = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + return formatter.format(now.current); + }, + }; +}); +// #endregion diff --git a/src/frameworks/react/demos/tutorial-7/src/prism.css b/src/frameworks/react/demos/tutorial-7/src/prism.css new file mode 100644 index 0000000..e2da571 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/prism.css @@ -0,0 +1,143 @@ +/** + * atom-dark theme for `prism.js` + * Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax + * @author Joe Gibson (@gibsjose) + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #c5c8c6; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier, monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #1d1f21; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #7c7c7c; +} + +.token.punctuation { + color: #c5c8c6; +} + +.namespace { + opacity: 0.7; +} + +.token.property, +.token.keyword, +.token.tag { + color: #96cbfe; +} + +.token.class-name { + color: #ffffb6; + text-decoration: underline; +} + +.token.boolean, +.token.constant { + color: #99cc99; +} + +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.number { + color: #ff73fd; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #a8ff60; +} + +.token.variable { + color: #c6c5fe; +} + +.token.operator { + color: #ededed; +} + +.token.entity { + color: #ffffb6; + cursor: help; +} + +.token.url { + color: #96cbfe; +} + +.language-css .token.string, +.style .token.string { + color: #87c38a; +} + +.token.atrule, +.token.attr-value { + color: #f9ee98; +} + +.token.function { + color: #dad085; +} + +.token.regex { + color: #e9c062; +} + +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} + +.token.italic { + font-style: italic; +} diff --git a/src/frameworks/react/demos/tutorial-7/src/stopwatch.ts b/src/frameworks/react/demos/tutorial-7/src/stopwatch.ts new file mode 100644 index 0000000..fd30032 --- /dev/null +++ b/src/frameworks/react/demos/tutorial-7/src/stopwatch.ts @@ -0,0 +1,24 @@ +import { Cell, Formula, Resource } from "@starbeam/universal"; + +export const Stopwatch = Resource((r) => { + const time = Cell(new Date()); + + const interval = setInterval(() => { + time.set(new Date()); + }, 1000); + + r.on.cleanup(() => { + clearInterval(interval); + }); + + return Formula(() => { + const now = time.current; + + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, + }).format(now); + }); +}); diff --git a/src/frameworks/react/tutorial/7-resources.md b/src/frameworks/react/tutorial/7-resources.md new file mode 100644 index 0000000..1893919 --- /dev/null +++ b/src/frameworks/react/tutorial/7-resources.md @@ -0,0 +1,107 @@ +# Resources + + + +So far, all of our reactive objects have been in-memory values that the garbage collector can clean up without any extra work. + +But we sometimes have values that represent a live resource that needs to be cleaned up once it's no longer used. + +For example, when you use `setInterval` to start a ticking clock, you need to call `clearInterval` once you no longer need it, or your timers will keep running in the background (a "leak"). + +In this example, we're creating a `Clock`: a custom reactive object whose value is the current time. + +## What We're Building + + + +## The Code: `Clock` Implementation + +```snippet {#clock} + +``` + +The `Clock`: + +- uses `Cell` to create a reactive variable that holds the current time +- sets up an interval to update the time every second +- registers an `on.cleanup` handler to clear the interval when the `Clock` is no longer used. +- returns an object with a `display` property that formats the time nicely. + +The `Clock` is written using Starbeam's _universal_ APIs, which means it will work in any framework with a Starbeam renderer (like React). + +The function passed to the `Resource` function is called the "resource constructor". It is called +when the component that uses the `Clock` is mounted, and whenever any reactive values used in the +constructor change. + +:::tip What are resources? + +Resources can: + +- create stable values (like cells and functions) +- register `on.cleanup` handlers, which run when the resource is cleaned up +- return reactive values or functions that read from the stable reactive values created in the resource constructor. + +Resources are cleaned up when: + +- React unmounts the component that used the resource (including temporary unmounting in React 18) +- Any reactive values used in the resource constructor change. + +A _resource_ is a self-contained way of creating a custom reactive object that needs to be cleaned +up. + +Once a resource is `use`d, it behaves like any other reactive value. For example, you could access +its properties in a function that's used in a component's render function, and the component would +re-render whenever the resource's properties change. + +::: + +## The Code: Using `Clock` in React + +```snippet {#usage} + +``` + +You can use the `Clock` in a React component by calling `use` with the `Clock` resource. The `use` +function returns a reactive values (just like a `Cell`), and you can use it alongside other reactive +values. + +## Using the `Clock` as a Reactive Value + +Instead of returning a formatted string, our resource returns the cell that holds the raw date. + +```snippet {#clock} + +``` + +In the component's setup function, we call `use(Clock)`, as before, write a function that +formats the time nicely, and use it in the component's render function. + +```snippet {#usage} + +``` + +:::💡 + +When a resource returns a reactive value, you don't need to call `.current` twice to get the value. +The resource's `.current` value is the current value of the reactive value it returns. + +::: + +The takeaway here is that resources are just a way of creating custom reactive objects that need to +be cleaned up. ==Once you've created a resource, you can use it just like any other reactive value.== + +This is powerful, because it allows you to freely compose reactive values, regardless of whether +they need to be cleaned up or not. + +## Thinking in Resources + +Resources are a different way of thinking about most "effects". + +For example, instead of thinking about `setInterval` as a side effect, you think about the current +time as a reactive value that changes over time, and which has cleanup logic. + +The only difference between a resource and any other custom reactive object is that you need to +instantiate it with `use` rather than just calling a function, but once you've done that, you can +use it just like any other reactive value.