This repository was archived by the owner on Jan 22, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Resisted prickly sand: add Optimistic Inputs to shared components #97
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
dc4d776
😜🍖 Checkpoint
ecd2a69
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
83b4b9c
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
2bd201e
🐦🕵 Checkpoint
8d1bda5
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
40089f9
🦃🏵 Checkpoint
1902cc1
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
111c911
🙍📮 Checkpoint
d5f1fb4
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
7fcbc2d
⛱🤖 Checkpoint
1041bd8
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
e124b9c
Merge branch 'master' of https://github.com/glitchdotcom/shared-compo…
863a879
nonworking thing
757825e
🚝🍋 Checkpoint
7f90e60
🛣🌦 Checkpoint
d90a55c
make things work
b4d0231
Merge branch 'master' into resisted-prickly-sand
75d39d7
optimistic input working
3ce478f
add wrapping text input
7b6ffb1
fix wrapping text input examples
b8b15fe
make more generic
de86092
delete config.txt, how did you get in here?
73a22f1
add prop type for onblur
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import React from 'react'; | ||
|
|
||
| const useDebouncedValue = (value, timeout) => { | ||
| const [debouncedValue, setDebouncedValue] = React.useState(value); | ||
|
|
||
| React.useEffect(() => { | ||
| const id = window.setTimeout(() => { | ||
| setDebouncedValue(value); | ||
| }, timeout); | ||
| return () => window.clearTimeout(id); | ||
| }, [value]); | ||
|
|
||
| return debouncedValue; | ||
| }; | ||
|
|
||
| export default useDebouncedValue; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import React from 'react'; | ||
|
|
||
| import useDebouncedValue from './use-debounced-value'; | ||
|
|
||
| /* | ||
|
|
||
| Use Optimistic Value: | ||
|
|
||
| - takes in an initial value for the input (this representes the real value the server last gave us) | ||
| - takes in a way to update the server | ||
|
|
||
| on change: | ||
| - we show them what they are typing (or editing in case of checkbox, etc), and OPTIMISTICALLY assume it all went according to plan | ||
| - if the server hits an error: | ||
| - we display that error to the user | ||
| - and we continue to show what the user's input even though it's not saved | ||
| - if the server succeeds: | ||
| - we pass along the response so that it can be stored in top level state later and passed back in again as props as the initial "real" value | ||
|
|
||
| on blur: | ||
| - if the user was in an errored state: | ||
| - we show the last saved good state and remove the error | ||
|
|
||
| */ | ||
|
|
||
| export default function useOptimisticValue(realValue, onChange, onBlur) { | ||
| // value undefined means that the field is unchanged from the 'real' value | ||
| const [state, setState] = React.useState({ value: undefined, error: null }); | ||
|
|
||
| // as the user types we save that as state.value, later as the user saves, we reset the state.value to undefined and instead show whatever value is passed in | ||
| const optimisticOnChange = (newValue) => setState({ value: newValue, error: null }); | ||
|
|
||
| // always show what the server knows, unless the user is currently typing something or we're loading an in-flight request | ||
| let optimisticValue = realValue; | ||
| if (state.value !== undefined) { | ||
| optimisticValue = state.value; | ||
| } | ||
|
|
||
| const debouncedValue = useDebouncedValue(state.value, 500); | ||
|
|
||
| React.useEffect(() => { | ||
| const ifUserHasTypedSinceLastSave = debouncedValue !== undefined; | ||
|
|
||
| if (ifUserHasTypedSinceLastSave) { | ||
| // if the value changes during the async action then ignore the result | ||
| const setStateIfStillRelevant = (newState) => setState((prevState) => (prevState.value === debouncedValue ? newState : prevState)); | ||
|
|
||
| // this scope can't be async/await because it's an effect | ||
| onChange(debouncedValue).then( | ||
| () => { | ||
| setStateIfStillRelevant({ value: undefined, error: null }); | ||
| }, | ||
| (error) => { | ||
| const message = (error && error.response && error.response.data && error.response.data.message) || 'Sorry, we had trouble saving. Try again later?'; | ||
| setStateIfStillRelevant({ value: debouncedValue, error: message }); | ||
| }, | ||
| ); | ||
| } | ||
| }, [debouncedValue]); | ||
|
|
||
| const optimisticOnBlur = (event) => { | ||
| // if you have already shown the user an error you can go ahead and hide it and revert back to last saved value | ||
| if (state.error) { | ||
| setState({ error: null, value: undefined }); | ||
| } | ||
| if (onBlur) { | ||
| onBlur(event); | ||
| } | ||
| }; | ||
|
|
||
| return [optimisticValue, optimisticOnChange, optimisticOnBlur, state.error]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { useState } from 'react'; | ||
|
|
||
| // always show untrimmed version to user, always send out trimmed version to server, onBlur show what was sent to the server | ||
| export default function usePassivelyTrimmedInput(rawInput, asyncUpdate, onBlur) { | ||
| const [untrimmedValue, setUntrimmedValue] = useState(rawInput); | ||
|
|
||
| const displayedInputValue = rawInput === untrimmedValue.trim() ? untrimmedValue : rawInput; | ||
|
|
||
| const wrapAsyncUpdateWithTrimmedValue = (value) => { | ||
| setUntrimmedValue(value); | ||
| return asyncUpdate(value.trim()); | ||
| }; | ||
|
|
||
| const wrapOnBlur = (event) => { | ||
| setUntrimmedValue(rawInput.trim()); | ||
| if (onBlur) { | ||
| onBlur(event); | ||
| } | ||
| }; | ||
|
|
||
| return [displayedInputValue, wrapAsyncUpdateWithTrimmedValue, wrapOnBlur]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| import React, { useState } from 'react'; | ||
| import PropTypes from 'prop-types'; | ||
| import { TextInput, WrappingTextInput } from './text-input'; | ||
|
|
||
| import { code, CodeExample, PropsDefinition, Prop } from './story-utils'; | ||
| import usePassivelyTrimmedInput from './hooks/use-passively-trimmed-input'; | ||
| import useOptimisticValue from './hooks/use-optimistic-value'; | ||
|
|
||
| export const OptimisticTextInput = ({ value, onChange, onBlur, ...props }) => ( | ||
| <OptimisticInput | ||
| Component={TextInput} | ||
| {...props} | ||
| value={value} | ||
| onChange={onChange} | ||
| onBlur={onBlur} | ||
| /> | ||
| ); | ||
|
|
||
| OptimisticTextInput.propTypes = { | ||
| label: PropTypes.string.isRequired, | ||
| value: PropTypes.string.isRequired, | ||
| onChange: PropTypes.func.isRequired, | ||
| onBlur: PropTypes.func, | ||
| }; | ||
|
|
||
| export const StoryOptimisticTextInput = () => { | ||
| const [successInputState, setSuccessInputState] = useState(''); | ||
| const [failedInputState, setFailedInputState] = useState(''); | ||
| return ( | ||
| <> | ||
| <p> | ||
| The OptimisticTextInput component renders a <a href="https://reactjs.org/docs/forms.html#controlled-components">controlled text input</a>{' '} | ||
| whose validity depends on a server call. It relies on useOptimsticValue which works like this: | ||
| </p> | ||
| <ul> | ||
| <li>takes in an initial value for the input (this representes the real value the server last gave us)</li> | ||
| <li>takes in a way to update the server</li> | ||
| </ul> | ||
| on change: | ||
| <ul> | ||
| <li>we show them what they are typing (or editing in case of checkbox, etc), and OPTIMISTICALLY assume it all went according to plan</li> | ||
| <li> | ||
| if the server hits an error: | ||
| <ul> | ||
| <li>we display that error to the user</li> | ||
| <li>and we continue to show what the user's input even though it's not saved</li> | ||
| </ul> | ||
| </li> | ||
| <li> | ||
| if the server succeeds: | ||
| <ul> | ||
| <li> | ||
| we pass along the response so that it can be stored in top level state later and passed back in again as props as the initial "real" | ||
| value | ||
| </li> | ||
| </ul> | ||
| </li> | ||
| <li> | ||
| on blur: | ||
| <ul>if the user was in an errored state, we show the last saved good state and remove the error</ul> | ||
| </li> | ||
| </ul> | ||
| <CodeExample> | ||
| {code` | ||
| const [inputState, setInputState] = useState(); | ||
|
|
||
| <OptimisticTextInput | ||
| label="Optimistic Input that will succeed" | ||
| value={inputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| await validateWithApi(); // should return a promise that either resolves or fails with an API error | ||
| setInputState(newValue); | ||
| }} | ||
| /> | ||
| <OptimisticTextInput | ||
| label="Optimistic Input that will fail" | ||
| value={inputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| // lets pretend this one is going to fail; the rejection should look like this | ||
| await Promise.reject({response: {data: {message: "It's an error"}}}); | ||
| setInputState(newValue); | ||
| }} | ||
| /> | ||
| `} | ||
| </CodeExample> | ||
| <PropsDefinition> | ||
| <Prop name="label" required> | ||
| Label for the text input | ||
| </Prop> | ||
| <Prop name="value" required> | ||
| Value of the text input. | ||
| </Prop> | ||
| <Prop name="onChange" required> | ||
| Function to call when the value changes. | ||
| </Prop> | ||
| <Prop name="onBlur" /> | ||
| </PropsDefinition> | ||
| <OptimisticTextInput | ||
| label="Optimistic Input that will succeed" | ||
| value={successInputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| await Promise.resolve(); | ||
| setSuccessInputState(newValue); | ||
| }} | ||
| /> | ||
| <OptimisticTextInput | ||
| label="Optimistic Input that will fail" | ||
| value={failedInputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| // lets pretend this one failed | ||
| await Promise.reject({ response: { data: { message: "It's an error" } } }); | ||
| setFailedInputState(newValue); | ||
| }} | ||
| /> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| export const OptimisticInput = ({ Component, value, onChange, onBlur, ...props }) => { | ||
| const [untrimmedValue, onChangeWithTrimmedInputs, onBlurWithTrimmedInputs] = usePassivelyTrimmedInput(value, onChange, onBlur); | ||
| const [optimisticValue, optimisticOnChange, optimisticOnBlur, optimisticError] = useOptimisticValue( | ||
| untrimmedValue, | ||
| onChangeWithTrimmedInputs, | ||
| onBlurWithTrimmedInputs, | ||
| ); | ||
|
|
||
| return <Component {...props} value={optimisticValue} error={optimisticError} onChange={optimisticOnChange} onBlur={optimisticOnBlur} />; | ||
| }; | ||
|
|
||
| OptimisticInput.propTypes = { | ||
| value: PropTypes.string.isRequired, | ||
| onChange: PropTypes.func.isRequired, | ||
| Component: PropTypes.elementType.isRequired, | ||
| onBlur: PropTypes.func, | ||
| }; | ||
|
|
||
| export default OptimisticInput; | ||
|
|
||
| export const Story_OptimisticInput = () => { | ||
| const [successInputState, setSuccessInputState] = useState(''); | ||
| const [failedInputState, setFailedInputState] = useState(''); | ||
| return ( | ||
| <> | ||
| <p> | ||
| Use this component to make any kind of input you want into one that behaves like <a href="#StoryOptimisticTextInput">Optimistic Text Input</a>. | ||
| </p> | ||
| <CodeExample> | ||
| {code` | ||
| const [inputState, setInputState] = useState(); | ||
|
|
||
| <OptimisticInput | ||
| Component={WrappingTextInput} | ||
| label="Optimistic Wrapping Text Input that will succeed" | ||
| value={inputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| await validateWithApi(); // should return a promise that either resolves or fails with an API error | ||
| setInputState(newValue); | ||
| }} | ||
| /> | ||
| <OptimisticInput | ||
| Component={WrappingTextInput} | ||
| label="Optimistic WrappingTextInput that will fail" | ||
| value={inputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| // lets pretend this one is going to fail; the rejection should look like this | ||
| await Promise.reject({response: {data: {message: "It's an error"}}}); | ||
| setInputState(newValue); | ||
| }} | ||
| /> | ||
| `} | ||
| </CodeExample> | ||
| <PropsDefinition> | ||
| <Prop name="Component" required> | ||
| Component to use as the base. Should accept props including at least <code>value</code>, <code>error</code>, <code>onChange</code>, and{' '} | ||
| <code>onBlur</code>. | ||
| </Prop> | ||
| <Prop name="label" required> | ||
| Label for the text input | ||
| </Prop> | ||
| <Prop name="value" required> | ||
| Value of the text input. | ||
| </Prop> | ||
| <Prop name="onChange" required> | ||
| Function to call when the value changes. | ||
| </Prop> | ||
| <Prop name="onBlur" /> | ||
| </PropsDefinition> | ||
| <div style={{ maxWidth: '150px' }}> | ||
| <OptimisticInput | ||
| Component={WrappingTextInput} | ||
| label="Optimistic Wrapping Input that will succeed" | ||
| value={successInputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| await Promise.resolve(); | ||
| setSuccessInputState(newValue); | ||
| }} | ||
| /> | ||
| <OptimisticInput | ||
| Component={WrappingTextInput} | ||
| label="Optimistic Wrapping Input that will fail" | ||
| value={failedInputState} | ||
| onChange={async (newValue) => { | ||
| // value changed; validate it with the server | ||
| // lets pretend this one failed | ||
| await Promise.reject({ response: { data: { message: "It's an error" } } }); | ||
| setFailedInputState(newValue); | ||
| }} | ||
| /> | ||
| </div> | ||
| </> | ||
| ); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should include onBlur as an optional function