diff --git a/.env.development b/.env.development index 1f194856..59e2435a 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,3 @@ SHOW_TEST_PAGES=true -SHOW_DRAFT_PAGES=true \ No newline at end of file +SHOW_DRAFT_PAGES=true +GITHUB_TOKEN=${GITHUB_TOKEN_READONLY} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f99b2997..1611a389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@blacksheepcode/example-react-autocomplete": "git+https://github.com/dwjohnston/example-react-autocomplete.git#c8e74acf9d10aef0dfc2b6f6d33ee811371ab99a", "@blacksheepcode/react-text-highlight": "^0.4.0", "@mdx-js/loader": "^3.0.1", "@mdx-js/mdx": "^3.0.1", @@ -526,6 +527,19 @@ "node": ">=6.9.0" } }, + "node_modules/@blacksheepcode/example-react-autocomplete": { + "name": "example-react-autocomplete", + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/dwjohnston/example-react-autocomplete.git#c8e74acf9d10aef0dfc2b6f6d33ee811371ab99a", + "integrity": "sha512-614u8N7AHeN2pcnIb1TcmNjvL/rzTAVEQVOuVqUJGL/l12LYuorRJI3G6N4d+M51qACmSWc76+SiJy2mpqBkfw==", + "dependencies": { + "@tanstack/react-query": "^5.87.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, "node_modules/@blacksheepcode/react-text-highlight": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@blacksheepcode/react-text-highlight/-/react-text-highlight-0.4.0.tgz", @@ -5316,6 +5330,30 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", + "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", + "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", + "dependencies": { + "@tanstack/query-core": "5.90.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/cypress": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.0.3.tgz", @@ -18252,6 +18290,14 @@ "@babel/helper-validator-identifier": "^7.27.1" } }, + "@blacksheepcode/example-react-autocomplete": { + "version": "git+ssh://git@github.com/dwjohnston/example-react-autocomplete.git#c8e74acf9d10aef0dfc2b6f6d33ee811371ab99a", + "integrity": "sha512-614u8N7AHeN2pcnIb1TcmNjvL/rzTAVEQVOuVqUJGL/l12LYuorRJI3G6N4d+M51qACmSWc76+SiJy2mpqBkfw==", + "from": "@blacksheepcode/example-react-autocomplete@git+https://github.com/dwjohnston/example-react-autocomplete.git#c8e74acf9d10aef0dfc2b6f6d33ee811371ab99a", + "requires": { + "@tanstack/react-query": "^5.87.1" + } + }, "@blacksheepcode/react-text-highlight": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@blacksheepcode/react-text-highlight/-/react-text-highlight-0.4.0.tgz", @@ -21112,6 +21158,19 @@ "tslib": "^2.8.0" } }, + "@tanstack/query-core": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", + "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==" + }, + "@tanstack/react-query": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", + "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", + "requires": { + "@tanstack/query-core": "5.90.5" + } + }, "@testing-library/cypress": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.0.3.tgz", diff --git a/package.json b/package.json index 2b8ff6be..56532e20 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@blacksheepcode/react-text-highlight": "^0.4.0", + "@blacksheepcode/example-react-autocomplete": "git+https://github.com/dwjohnston/example-react-autocomplete.git#c8e74acf9d10aef0dfc2b6f6d33ee811371ab99a", "@mdx-js/loader": "^3.0.1", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", diff --git a/src/app/globals.css b/src/app/globals.css index b1d34dd9..248bbcb7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -57,6 +57,10 @@ body { --column-width: 1100px; } +img { + max-width: 100%; +} + pre, pre:hover { white-space: pre-wrap; diff --git a/src/assets/autocomplete.png b/src/assets/autocomplete.png new file mode 100644 index 00000000..8123ddfe Binary files /dev/null and b/src/assets/autocomplete.png differ diff --git a/src/components/BlogPostFrame/react-text-highlight.css b/src/components/BlogPostFrame/react-text-highlight.css index efa3d316..d1c7e724 100644 --- a/src/components/BlogPostFrame/react-text-highlight.css +++ b/src/components/BlogPostFrame/react-text-highlight.css @@ -56,7 +56,7 @@ @media (prefers-color-scheme: dark) { :root { - --highlight-bg-color: #4a4b80; + --highlight-bg-color: #5790d459; --highlight-text-color: #fff; --highlight-bg-color-hover: #4a4b80; --highlight-border-hover: #5b5f99; diff --git a/src/demos/encapsulate-state/AutocompleteDemo.tsx b/src/demos/encapsulate-state/AutocompleteDemo.tsx new file mode 100644 index 00000000..a8f58063 --- /dev/null +++ b/src/demos/encapsulate-state/AutocompleteDemo.tsx @@ -0,0 +1,91 @@ + +"use client"; +import { AutocompleteB } from "@blacksheepcode/example-react-autocomplete"; + + +type TestItem = { + id: number; + name: string; + description: string; +}; + +const mockItems: TestItem[] = [ + { id: 1, name: 'Apple', description: 'A red fruit' }, + { id: 2, name: 'Banana', description: 'A yellow fruit' }, + { id: 3, name: 'Cherry', description: 'A small red fruit' }, + { id: 4, name: 'Dragonfruit', description: 'A vibrant exotic fruit with a speckled interior' }, + { id: 5, name: 'Elderberry', description: 'Small dark berries often used in syrups' }, + { id: 6, name: 'Fig', description: 'A sweet, soft fruit with many seeds' }, + { id: 7, name: 'Grape', description: 'Small round fruit used for snacking and wine' }, + { id: 8, name: 'Honeydew', description: 'A sweet, pale green melon' }, + { id: 9, name: 'Indian Fig', description: 'Also known as prickly pear, a desert fruit' }, + { id: 10, name: 'Jackfruit', description: 'A large tropical fruit with sweet flesh' }, + { id: 11, name: 'Kiwi', description: 'A small fruit with fuzzy skin and bright green flesh' }, + { id: 12, name: 'Lemon', description: 'A tart yellow citrus fruit' }, + { id: 13, name: 'Mango', description: 'A juicy tropical stone fruit' }, + { id: 14, name: 'Nectarine', description: 'A smooth-skinned variety of peach' }, + { id: 15, name: 'Orange', description: 'A sweet citrus fruit' }, + { id: 16, name: 'Papaya', description: 'A soft, tropical fruit with orange flesh' }, + { id: 17, name: 'Quince', description: 'A yellow fruit often cooked into jams' }, + { id: 18, name: 'Raspberry', description: 'A small red berry with a tart-sweet flavor' }, + { id: 19, name: 'Strawberry', description: 'A popular red berry with a sweet taste' }, + { id: 20, name: 'Tomato', description: 'Botanically a fruit commonly used as a vegetable' }, + { id: 21, name: 'Ugli Fruit', description: 'A tangy citrus hybrid with rough skin' }, + { id: 22, name: 'Vanilla', description: 'A fragrant pod used for flavoring (technically an orchid fruit)' }, + { id: 23, name: 'Watermelon', description: 'A large, juicy melon with green rind and red flesh' }, + { id: 24, name: 'Xigua', description: 'Another name for watermelon, used in some regions' }, + { id: 25, name: 'Yuzu', description: 'A tart citrus used in Japanese cuisine' }, + { id: 26, name: 'Zucchini', description: 'A summer squash often treated as a vegetable' }, + { id: 27, name: 'Pineapple', description: 'A tropical fruit with sweet, juicy flesh and spiky skin' }, + { id: 28, name: 'Crabapple', description: 'A small tart apple often used for jams and preserves' }, + { id: 29, name: 'Apple Cider', description: 'A beverage made from pressed apples, often enjoyed warm or spiced' }, + { id: 30, name: 'Black Cherry', description: 'A dark-sweet cherry variety' }, + { id: 31, name: 'Maraschino Cherry', description: 'A preserved, sweet, bright red cherry used for desserts and cocktails' }, + { id: 32, name: 'Sour Cherry', description: 'A tart cherry commonly used in baking and preserves' }, + { id: 33, name: 'Concord Grape', description: 'A dark purple grape used for juice and jelly' }, + { id: 34, name: 'Grape Jelly', description: 'A sweet spread made from grapes' }, + { id: 35, name: 'Grapefruit', description: 'A large citrus fruit with a tangy, slightly bitter flavor' }, +]; + + +const searchFn = async (searchTerm: string, pageNumber: number) => { + await new Promise(resolve => setTimeout(resolve, 300)); + + if (!searchTerm) { + + return { + items: [], + pageMeta: { + totalResults: 0, + pageNumber: 1, + resultsPerPage: 10, + }, + }; + } + + const filteredItems = mockItems.filter(item => + (`${item.name} ${item.description}`).toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return { + items: filteredItems, + pageMeta: { + totalResults: filteredItems.length, + pageNumber: pageNumber, + resultsPerPage: 10, + }, + }; +} +export function AutocompleteDemo() { + return ( +
+
{item.name} - {item.description}
} + itemKey="id" + selectedValueDisplayStringFn={(item) => item.name} + + /> +
+ ); +} diff --git a/src/demos/encapsulate-state/SpecialButtonDemo.tsx b/src/demos/encapsulate-state/SpecialButtonDemo.tsx new file mode 100644 index 00000000..e04945dd --- /dev/null +++ b/src/demos/encapsulate-state/SpecialButtonDemo.tsx @@ -0,0 +1,9 @@ +"use client"; +import { SpecialButton2 } from "@blacksheepcode/example-react-autocomplete"; + +export function SpecialButtonDemo() { + return { + await new Promise((res) => setTimeout(res, 1000)); + return { success: true }; + }} /> +} \ No newline at end of file diff --git a/src/routes/posts/encapsulate_as_much_state_as_possible.mdx b/src/routes/posts/encapsulate_as_much_state_as_possible.mdx new file mode 100644 index 00000000..d10da11b --- /dev/null +++ b/src/routes/posts/encapsulate_as_much_state_as_possible.mdx @@ -0,0 +1,191 @@ +--- +meta: + title: Encapsulate as much state as possible in your component + description: A common pattern I see is passing every bit of a component's state in as a prop. This just puts the responsibility of that component's actual useful logic on to the parent. + dateCreated: 2025-10-31 + image: "autocomplete" + +tags: + - "react" + - "testing" + +--- +import { GithubPermalinkRsc } from "react-github-permalink/dist/rsc"; +import { SpecialButtonDemo } from "@/demos/encapsulate-state/SpecialButtonDemo"; +import { AutocompleteDemo } from "@/demos/encapsulate-state/AutocompleteDemo"; +import { TextHighlight } from "@blacksheepcode/react-text-highlight"; + +import { DemoFrame } from "@/components/DemoFrame/DemoFrame"; + +Let's say we have a nice simple component like this: + +Click the button to see it transition from pending to loading to success.

}> + +
+ + + +You click the button, it transitions to a loading state, then it transitions to either a success or error state. + +What I commonly see, is the component will be implemented with a interface like this: + + + +
+Implementation + + +
+ +Whereby every bit of state that component could be in, is controlled by the parent and passed in as props. + +## Interfaces like this are a mistake + +There's a sense that components with an interface like this would be easy to test, we could write something like: + + + +It might be desirable to to write examples like this for Storybook, where we _just_ want to see the component as it is loading. + +But these tests do not test one of the key aspects of what we are interested in here - the state transitions. + +If we did want to test the state transitions, we could do it one of two ways: + +### 1. Create a wrapper component to contain the state in + + + +Here, we are getting a good demonstration of 'tests are test of how usable your code is' - that we're having to create a bit of external state and manage its transitions in the test, is _also what every consumer of our component is going to have to do_. + +### 2. Change the props via `rerender` + + + +This is no test at all. We're simply stipulating that the props changed accurately, there's no guarantee that our parent component will in fact make this change correctly. + +What this kind of interface really does, is it _makes the parent responsible for the logic of the state transition_ from pending to loading to error/success. + +Remember, the above example is a _very simple_ component. An Autocomplete component, which we will look at later, will contain state about what text the user has entered, whether it is loading, are there results, and so forth. + +## The better interface - one that encapsulates the state transitions internally. + +If instead we create an interface like this: + + + +Note the difference being that this `onClick` handler returns a `Promise<{success: boolean}>`. + +
+Implementation + + +
+ +Now we can write our tests like this: + + + +This is much nicer! + +Our test now resembles how we're going to use the component in practise, and it's very straight forward what's happening. + +## There's nothing say that you couldn't do both + +Technically what we could do is do something like expose these components with less functionality as `SpecialButtonStateless` and then use this component in our `SpecialButton` component that then provides the the functionality. + +I think this would be of limited use - but might be helpful in a larger team with a dedicated design system, and wanting to see the component state statically. +I would argue that the functioning `SpecialButton` component that is the important for actually building the application. + +## Why is this pattern so common? + +I suspect a big part of the reason that this pattern is so prevalent, is because of tools like Tanstack Query, which provide an interface that, to simplify, looks like this: + +```typescript +type LoadingResult = { + data: T; + state: "success" +}| { + data: null; + state: "pending" | "error" | "loading" +} +``` + +that is, they're not providing async functions as their primary means of interface. + +When developers see that their means for fetching data looks like this, then they're going to tend to reproduce that interface onto the components they're creating. + +RTKQ does helpfully provide a [useLazyQuery](https://redux-toolkit.js.org/rtk-query/api/created-api/hooks#uselazyquery) hook which lends itself more to the style that I advocate. + +You can see [this discussion on TanStack's Github](https://github.com/TanStack/query/discussions/1205) the basic answer to why a lazy/imperative function is not provided, is because it would be trivial for someone to implement themselves. + +Here is how we extract such a lazy query from TanStack - it really is not well advertised: + + + +We use TanStack Query's [`queryClient.ensureQueryData`](https://tanstack.com/query/v4/docs/reference/QueryClient#queryclientensurequerydata) which is essentially a lazy query that will return the data if it already cached. + +## This isn't an argument against state management tools like TanStack Query + +This isn't at all to say that tools like Tanstack are bad. Tools like Tanstack are very useful in that they provide query deduplication and caching. + +However, async functions as a particular example, are often the right abstraction to represent 'user does a thing, it loads for a bit, and then the state changes'. + +## A more complex example - Autocomplete + +Here we have an Autocomplete component: + +

Enter some search terms to see the behaviour of the Autocomplete component

+ Hint: Use the terms 'apple', 'cherry' or 'grape'

}> + +
+ +Importantly, an autocomplete is a _non-trivial_ component. Done well, an autocomplete needs to handle: + +- Debouncing the search - so each keystroke doesn't trigger a new API request. +- Cancelling previous requests - we don't want a situtation where a slower earlier request clobbers the the latest request. +- Pagination - if the API response is paginated and the user scrolls the bottom of the list, we need to load more. + +The above example _does not_ implement these behaviours - + Also, I didn't want to spend too much time on this. +

}>this is an intentional decision - it represents the realistic evolution of a codebase as functionality is added or extended.
+ +Keep this in mind. + +A naive interface for this component might look like this: + + + +And then we could use this in our application like this: + + + +The consuming component needs to manage _three_ pieces of state to make this work, and needs to implement all of the loading logic. + +Now imagine if you had a form that had three of these Autocompletes! In fairness, you would probably use some kind of higher-order function to provide this.

}>Imagine if you were also handling debouncing, cancellation and pagination logic!
+ +Chances are, the developer will be See Assume the next developer will copy paste what they see you do.

}>copy-pasting
what they see, and copy pasting is always prone to copy paste errors. + +A better interface looks like this: + + + +The use of it is _much_ simpler: + + + +And writing tests make sense: + + + +Importantly, coming back to the debouncing, + Cancelling logic will require changing the component interface, such that the searchFn returns some kind of AbortablePromise instead of a regular Promise. +

}>cancelling
, pagination logic, all of that is _encapsulated_, hidden away inside, the component - the consumer does not need to think about, it's all taken care of for them. + +Now you might think that that

+ Not to mention that this implementation doesn't allow for an initially selected value - we would need to add an `initialValue` and an async function `fetchValueById` props.

The thinking here is that we shouldn't need a full `T` object to provide as the initial value. Often when a page or form loads - we just have the ID of the thing (a userId, a todoId, etc).

+ Rather than the parent component loading the thing, and _then_ showing the autocomplete, the parent component simply provides an async function in order for the Autocomplete to fetch it itself.

+}>`selectedValueDisplayStringFn` stuff
doesn't really look like we're _simplifying_ the interface - but we are. + +The component interface is _forcing_ the developer handle the transition from 'having a search term entered and selecting an item' to 'an item is selected and something is displayed in the search box', and it does this does this in a manner that can't forgotten (you'll get a type error). + +The complexity of the interface comes from the inherrent complexity of the problem space - and as much as possible, the component has already done the thinking for you as a developer - and it's telling you 'these are the decision you need to make - what should be displayed when an item is selected?'. \ No newline at end of file