Skip to content
This repository was archived by the owner on Jan 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .glitch-assets
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,7 @@
{"name":"boosted-default.png","date":"2020-03-03T16:38:18.622Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Fboosted-default.png","type":"image/png","size":1749,"imageWidth":36,"imageHeight":58,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Fboosted-default.png","thumbnailWidth":36,"thumbnailHeight":58,"uuid":"NlCywvr7c5Qp0YVC"}
{"name":"Group 101 1 (1).png","date":"2020-03-03T17:46:13.033Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2FGroup%20101%201%20(1).png","type":"image/png","size":2941,"imageWidth":58,"imageHeight":94,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2FGroup%20101%201%20(1).png","thumbnailWidth":58,"thumbnailHeight":94,"uuid":"zM9SFr4Y9dOcNnfx"}
{"name":"Group 101 1.png","date":"2020-03-03T17:46:19.011Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2FGroup%20101%201.png","type":"image/png","size":2669,"imageWidth":58,"imageHeight":94,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2FGroup%20101%201.png","thumbnailWidth":58,"thumbnailHeight":94,"uuid":"y8aM7PbLAOxUVh8r"}
{"name":"footer_icon_twitter.png","date":"2020-03-12T17:33:09.011Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_twitter.png","type":"image/png","size":870,"imageWidth":48,"imageHeight":48,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_twitter.png","thumbnailWidth":48,"thumbnailHeight":48,"uuid":"WJXWorBo7FwmRdbx"}
{"name":"footer_icon_dev.png","date":"2020-03-12T17:33:11.207Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_dev.png","type":"image/png","size":641,"imageWidth":48,"imageHeight":48,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_dev.png","thumbnailWidth":48,"thumbnailHeight":48,"uuid":"lje5yHtXnqOmHRxb"}
{"name":"footer_icon_linkedin.png","date":"2020-03-12T17:33:13.831Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_linkedin.png","type":"image/png","size":542,"imageWidth":48,"imageHeight":48,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ffooter_icon_linkedin.png","thumbnailWidth":48,"thumbnailHeight":48,"uuid":"CyhOLRNDF9dpFOQw"}
{"name":"icon_external.png","date":"2020-03-12T17:33:16.451Z","url":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ficon_external.png","type":"image/png","size":284,"imageWidth":48,"imageHeight":48,"thumbnail":"https://cdn.glitch.com/d7f4f279-e13b-4330-8422-00b2d9211424%2Ficon_external.png","thumbnailWidth":48,"thumbnailHeight":48,"uuid":"uu8bnjx4SkXSFLc4"}
16 changes: 16 additions & 0 deletions lib/hooks/use-debounced-value.js
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;
72 changes: 72 additions & 0 deletions lib/hooks/use-optimistic-value.js
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];
}
22 changes: 22 additions & 0 deletions lib/hooks/use-passively-trimmed-input.js
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];
}
219 changes: 219 additions & 0 deletions lib/optimistic-inputs.js
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,
Copy link
Copy Markdown
Contributor

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

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>
</>
);
};
4 changes: 3 additions & 1 deletion lib/stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as textInput from './text-input';
import * as themePreview from './theme-preview';
import * as toggle from './toggle';
import * as visuallyHidden from './visually-hidden';
import * as optimisticInputs from './optimistic-inputs';

const modules = [
animationContainer,
Expand All @@ -40,6 +41,7 @@ const modules = [
loader,
mark,
notification,
optimisticInputs,
overlay,
popover,
progress,
Expand All @@ -49,7 +51,7 @@ const modules = [
textInput,
themePreview,
toggle,
visuallyHidden,
visuallyHidden,
];

const { RootStyle } = system;
Expand Down
Loading