├── .prettierrc ├── src ├── App │ ├── styles.module.css │ └── index.js ├── Navigation │ ├── styles.module.css │ └── index.js ├── Home │ ├── styles.module.css │ └── index.js ├── User │ ├── styles.module.css │ ├── UserList │ │ ├── styles.module.css │ │ └── index.js │ ├── UserDetails │ │ ├── styles.module.css │ │ └── index.js │ ├── UserForm │ │ ├── styles.module.css │ │ └── index.js │ └── index.js ├── AutoSave │ ├── AutoSaveContext.js │ ├── index.js │ ├── styles.module.css │ └── AutoSaveIndicator.js ├── index.js ├── index.css ├── hooks │ └── useClickOutside.js ├── Dialog │ ├── styles.module.css │ └── index.js └── api.js ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .gitignore ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 70 4 | } -------------------------------------------------------------------------------- /src/App/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: 'relative'; 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-road-to-learn-react/react-autosave-example/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-road-to-learn-react/react-autosave-example/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-road-to-learn-react/react-autosave-example/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/Navigation/styles.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | } 4 | 5 | .item { 6 | margin-left: 8px; 7 | } 8 | -------------------------------------------------------------------------------- /src/Home/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 9px; 3 | padding: 16px; 4 | border: 1px solid #000000; 5 | } 6 | -------------------------------------------------------------------------------- /src/User/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 9px; 3 | padding: 16px; 4 | border: 1px solid #000000; 5 | } 6 | -------------------------------------------------------------------------------- /src/User/UserList/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 9px; 3 | padding: 16px; 4 | border: 1px solid #000000; 5 | } 6 | -------------------------------------------------------------------------------- /src/User/UserDetails/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 9px; 3 | padding: 16px; 4 | border: 1px solid #000000; 5 | } 6 | -------------------------------------------------------------------------------- /src/AutoSave/AutoSaveContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const AutoSaveContext = React.createContext(false); 4 | 5 | export default AutoSaveContext; 6 | -------------------------------------------------------------------------------- /src/AutoSave/index.js: -------------------------------------------------------------------------------- 1 | import AutoSaveContext from './AutoSaveContext'; 2 | import AutoSaveIndicator from './AutoSaveIndicator'; 3 | 4 | export { AutoSaveContext, AutoSaveIndicator }; 5 | -------------------------------------------------------------------------------- /src/User/UserForm/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 9px; 3 | padding: 16px; 4 | border: 1px solid #000000; 5 | } 6 | 7 | .form { 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | -------------------------------------------------------------------------------- /src/AutoSave/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | top: -8px; 4 | right: -8px; 5 | background-color: #000000; 6 | color: #ffffff; 7 | margin: 9px; 8 | padding: 16px; 9 | border: 1px solid #000000; 10 | } 11 | -------------------------------------------------------------------------------- /src/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const Home = () => { 6 | return ( 7 |
Home of this website!
8 | ); 9 | }; 10 | 11 | export default Home; 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './index.css'; 5 | import App from './App'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | font-family: Helvetica, Arial, Sans-Serif; 4 | } 5 | 6 | ul, 7 | li { 8 | margin: 0; 9 | padding: 0; 10 | list-style-type: none; 11 | } 12 | 13 | button { 14 | background: none; 15 | color: inherit; 16 | border: 1px solid #000000; 17 | padding: 0; 18 | font: inherit; 19 | cursor: pointer; 20 | outline: inherit; 21 | padding: 8px; 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/AutoSave/AutoSaveIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AutoSaveContext from './AutoSaveContext'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const AutoSaveIndicator = () => { 8 | const { isAutoSaving } = React.useContext(AutoSaveContext); 9 | 10 | if (!isAutoSaving) { 11 | return null; 12 | } 13 | 14 | return
Saving ...
; 15 | }; 16 | 17 | export default AutoSaveIndicator; 18 | -------------------------------------------------------------------------------- /src/Navigation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import styles from './styles.module.css'; 5 | 6 | const Navigation = () => ( 7 | 17 | ); 18 | 19 | export default Navigation; 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const useClickOutside = (ref, handler) => { 4 | React.useEffect(() => { 5 | const listener = (event) => { 6 | if (!ref.current || ref.current.contains(event.target)) { 7 | return; 8 | } 9 | 10 | handler(event); 11 | }; 12 | 13 | document.addEventListener('mousedown', listener); 14 | document.addEventListener('touchstart', listener); 15 | 16 | return () => { 17 | document.removeEventListener('mousedown', listener); 18 | document.removeEventListener('touchstart', listener); 19 | }; 20 | }, [ref, handler]); 21 | }; 22 | 23 | export default useClickOutside; 24 | -------------------------------------------------------------------------------- /src/User/UserList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const UserList = ({ users, selectedUserId, onSelectUserId }) => { 6 | return ( 7 |
8 | 23 |
24 | ); 25 | }; 26 | 27 | export default UserList; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-autosave", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.13.1", 7 | "react-dom": "^16.13.1", 8 | "react-router-dom": "^5.2.0", 9 | "react-scripts": "3.4.3" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "test": "react-scripts test", 15 | "eject": "react-scripts eject" 16 | }, 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Dialog/styles.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 8px 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: space-between; 6 | height: 64px; 7 | border-bottom: 1px solid #dddddd; 8 | } 9 | 10 | .content { 11 | flex: 1; 12 | padding: 16px; 13 | } 14 | 15 | .footer { 16 | margin: 8px; 17 | display: flex; 18 | justify-content: space-between; 19 | } 20 | 21 | .overlay { 22 | position: fixed; 23 | top: 0; 24 | left: 0; 25 | width: 100%; 26 | height: 100%; 27 | background: #ffffff; 28 | filter: opacity(0.8); 29 | z-index: 15; 30 | } 31 | 32 | .dialog { 33 | position: fixed; 34 | top: 50%; 35 | left: 50%; 36 | z-index: 25; 37 | transform: translate(-50%, -50%); 38 | display: flex; 39 | flex-direction: column; 40 | min-width: 420px; 41 | min-height: 360px; 42 | background: #ffffff; 43 | box-shadow: 0 0 6px 0 #dddddd; 44 | } 45 | -------------------------------------------------------------------------------- /src/User/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | import UserList from './UserList'; 6 | import UserDetails from './UserDetails'; 7 | import { fetchUsers } from '../api'; 8 | 9 | const Users = () => { 10 | const [users, setUsers] = React.useState(null); 11 | const [selectedUserId, setSelectedUserId] = React.useState(null); 12 | 13 | React.useEffect(() => { 14 | const loadUsers = async () => { 15 | const result = await fetchUsers(); 16 | setUsers(result); 17 | setSelectedUserId(result[0]); 18 | }; 19 | 20 | loadUsers(); 21 | }, []); 22 | 23 | if (!users) { 24 | return
Loading users ...
; 25 | } 26 | 27 | return ( 28 | <> 29 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default Users; 40 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | // pseudo database 2 | let users = { 3 | 1: { 4 | id: '1', 5 | firstName: 'Robin', 6 | middleName: '', 7 | lastName: 'Wieruch', 8 | birthday: new Date(), 9 | gender: 'MALE', 10 | }, 11 | 2: { 12 | id: '2', 13 | firstName: 'Dave', 14 | middleName: '', 15 | lastName: 'Davddis', 16 | birthday: new Date(), 17 | gender: 'MALE', 18 | }, 19 | }; 20 | 21 | // pseudo API 22 | export const fetchUsers = () => 23 | new Promise((resolve) => 24 | setTimeout(() => resolve(Object.keys(users)), 500) 25 | ); 26 | 27 | export const fetchUser = (id) => 28 | new Promise((resolve) => setTimeout(() => resolve(users[id]), 500)); 29 | 30 | export const updateUser = (id, data) => 31 | new Promise((resolve) => { 32 | const { [id]: user, ...rest } = users; 33 | 34 | if (!user) { 35 | return setTimeout(() => resolve(false), 50); 36 | } 37 | 38 | const modifiedUser = { ...user, ...data }; 39 | users = { ...rest, [id]: modifiedUser }; 40 | 41 | return setTimeout(() => resolve(true), 500); 42 | }); 43 | -------------------------------------------------------------------------------- /src/App/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BrowserRouter as Router, 4 | Switch, 5 | Route, 6 | } from 'react-router-dom'; 7 | 8 | import styles from './styles.module.css'; 9 | 10 | import Navigation from '../Navigation'; 11 | import User from '../User'; 12 | import Home from '../Home'; 13 | import { AutoSaveContext, AutoSaveIndicator } from '../AutoSave'; 14 | 15 | const App = () => { 16 | const [isAutoSaving, setIsAutoSaving] = React.useState(false); 17 | 18 | return ( 19 | 20 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /src/Dialog/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import styles from './styles.module.css'; 5 | 6 | const DialogState = ({ children }) => { 7 | const [open, setOpen] = React.useState(false); 8 | 9 | return children(open, setOpen); 10 | }; 11 | 12 | const DialogHeader = ({ children }) => ( 13 |
14 |

{children}

15 |
16 | ); 17 | 18 | const DialogContent = ({ children }) => ( 19 |
{children}
20 | ); 21 | 22 | const DialogFooter = ({ left, right }) => ( 23 |
24 | {left} 25 | {right} 26 |
27 | ); 28 | 29 | const Dialog = ({ children }) => 30 | ReactDOM.createPortal( 31 | <> 32 |
33 |
{children}
34 | , 35 | document.body 36 | ); 37 | 38 | Dialog.DialogState = DialogState; 39 | Dialog.DialogHeader = DialogHeader; 40 | Dialog.DialogFooter = DialogFooter; 41 | Dialog.DialogContent = DialogContent; 42 | 43 | export default Dialog; 44 | -------------------------------------------------------------------------------- /src/User/UserDetails/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | import UserForm from '../UserForm'; 6 | import { AutoSaveContext } from '../../AutoSave'; 7 | import { fetchUser, updateUser } from '../../api'; 8 | 9 | const UserDetails = ({ userId }) => { 10 | const { setIsAutoSaving } = React.useContext(AutoSaveContext); 11 | 12 | const [user, setUser] = React.useState(null); 13 | 14 | const loadUser = React.useCallback(async (id) => { 15 | setUser(await fetchUser(id)); 16 | }, []); 17 | 18 | React.useEffect(() => { 19 | setUser(null); 20 | 21 | if (userId) { 22 | loadUser(userId); 23 | } 24 | }, [loadUser, userId]); 25 | 26 | const handleUpdateUser = async (changes) => { 27 | setIsAutoSaving(true); 28 | await updateUser(userId, changes); 29 | setIsAutoSaving(false); 30 | 31 | await loadUser(userId); 32 | }; 33 | 34 | if (!user) { 35 | return ( 36 |
37 | Loading selected user ... 38 |
39 | ); 40 | } 41 | 42 | return ; 43 | }; 44 | 45 | export default UserDetails; 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Autosave by Example 2 | 3 | Auto save runs every time a user clicks something (e.g. button, option, select) or leaves an input field. 4 | 5 | ![Sep-05-2020 11-08-47](https://user-images.githubusercontent.com/2479967/92301951-44e95100-ef68-11ea-9b28-bc4211b9063d.gif) 6 | 7 | If a user navigates away (React Router) or hides the component ([conditional rendering](https://www.robinwieruch.de/conditional-rendering-react)), the form is auto saved if all required fields are filled. 8 | 9 | ![Sep-05-2020 11-19-44](https://user-images.githubusercontent.com/2479967/92302116-c8577200-ef69-11ea-96a2-3309ccfba63b.gif) 10 | 11 | If a required field isn't filled (here "First Name"), there will be an intercepting dialog which asks you to discard the changes ("Discard" button) or to continue with the form ("Cancel" button). 12 | 13 | ![Sep-05-2020 11-10-48](https://user-images.githubusercontent.com/2479967/92301977-824dde80-ef68-11ea-926f-f9af751be778.gif) 14 | 15 | Caveat: The intercepting Dialog is triggered whenever a user clicks outside of the form and not all required fields are filled to be saved. An alternative implementation would be to call this dialog only if a user navigates away (e.g. click "Home" link) or removes the component (e.g. click "User ID: 2" option). The former could be easily integrated once in React Router. However, the latter would need to be implemented for every user interaction (e.g. "User ID: 2"), which removes the form, on this page. 16 | 17 | ## Installation 18 | 19 | - `git clone git@github.com:the-road-to-learn-react/react-autosave-example.git` 20 | - cd react-autosave-example 21 | - npm install 22 | - npm start 23 | - visit `http://localhost:3000` 24 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/User/UserForm/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | import Dialog from '../../Dialog'; 6 | import useClickOutside from '../../hooks/useClickOutside'; 7 | 8 | const toDomDate = (date) => { 9 | if (!date) { 10 | return null; 11 | } 12 | 13 | return date.toISOString().split('T')[0]; 14 | }; 15 | 16 | const fromDomDate = (date) => new Date(date); 17 | 18 | const usePropsSyncedState = (prop) => { 19 | const [model, setModel] = React.useState(prop); 20 | 21 | const propRef = React.useRef(prop); 22 | 23 | React.useEffect(() => { 24 | if (propRef.current !== prop) { 25 | setModel({ ...prop, ...model }); 26 | 27 | propRef.current = prop; 28 | } 29 | }, [prop, model]); 30 | 31 | return [model, setModel]; 32 | }; 33 | 34 | const REQUIRED_FIELDS = [ 35 | 'firstName', 36 | 'lastName', 37 | 'birthday', 38 | 'gender', 39 | ]; 40 | 41 | const UserForm = ({ user, onUpdateUser }) => { 42 | const [isPrompt, setIsPrompt] = React.useState(false); 43 | 44 | const [userModel, setUserModel] = usePropsSyncedState(user); 45 | 46 | const handleChanges = (key, value, withCommit) => { 47 | setUserModel({ 48 | ...userModel, 49 | [key]: value, 50 | }); 51 | 52 | if (withCommit) handleCommit(); 53 | }; 54 | 55 | const handleCommit = async () => { 56 | const hasAllRequired = REQUIRED_FIELDS.reduce((acc, value) => { 57 | if (!userModel[value]) { 58 | acc = false; 59 | } 60 | 61 | return acc; 62 | }, true); 63 | 64 | const hasChanged = user !== userModel; 65 | 66 | if (hasAllRequired && hasChanged) { 67 | await onUpdateUser(userModel); 68 | } 69 | }; 70 | 71 | const boundaryRef = React.useRef(); 72 | useClickOutside(boundaryRef, () => { 73 | const hasAllRequired = REQUIRED_FIELDS.reduce((acc, value) => { 74 | if (!userModel[value]) { 75 | acc = false; 76 | } 77 | 78 | return acc; 79 | }, true); 80 | 81 | const hasChanged = user !== userModel; 82 | 83 | if (!hasAllRequired && hasChanged) { 84 | setIsPrompt(true); 85 | } 86 | }); 87 | 88 | const handleCancel = () => { 89 | setIsPrompt(false); 90 | }; 91 | 92 | const handleDiscard = () => { 93 | setIsPrompt(false); 94 | setUserModel(user); 95 | }; 96 | 97 | return ( 98 |
99 | {isPrompt && ( 100 | 101 | Unsaved Changes 102 | 103 | 104 | There are unsaved changes ... 105 | 106 | 107 | 110 | Cancel 111 | 112 | } 113 | right={ 114 | 117 | } 118 | /> 119 | 120 | )} 121 | 122 |
123 | 124 | { 129 | handleChanges('firstName', e.target.value, false); 130 | }} 131 | onBlur={handleCommit} 132 | /> 133 | 134 | 135 | 140 | handleChanges('middleName', e.target.value) 141 | } 142 | onBlur={handleCommit} 143 | /> 144 | 145 | 146 | handleChanges('lastName', e.target.value)} 151 | onBlur={handleCommit} 152 | /> 153 | 154 | 155 | 160 | handleChanges( 161 | 'birthday', 162 | fromDomDate(e.target.value), 163 | true 164 | ) 165 | } 166 | /> 167 | 168 | 169 | 180 |
181 |
182 | ); 183 | }; 184 | 185 | export default UserForm; 186 | --------------------------------------------------------------------------------