├── .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 |
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 | 
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 | 
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 | 
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 |
182 | );
183 | };
184 |
185 | export default UserForm;
186 |
--------------------------------------------------------------------------------