├── .env ├── .gitignore ├── README.md ├── firebase.json ├── package-lock.json ├── package.json ├── public ├── index.html └── robots.txt └── src ├── App.js ├── App.test.js ├── components └── ErrorMessage │ ├── ErrorMessage.css │ └── ErrorMessage.js ├── hooks └── useQueryString.js ├── index.css ├── index.js ├── scenes ├── CreateList │ ├── CreateList.css │ └── CreateList.js ├── EditList │ ├── AddItem │ │ ├── AddItem.css │ │ └── AddItem.js │ ├── EditList.css │ ├── EditList.js │ └── ItemList │ │ └── ItemList.js └── JoinList │ ├── JoinList.css │ └── JoinList.js ├── services └── firestore.js └── setupTests.js /.env: -------------------------------------------------------------------------------- 1 | # Add your Firebase web app configurations. 2 | # See Project Settings for your Firebase project at https://console.firebase.google.com. 3 | REACT_APP_FIREBASE_API_KEY= 4 | REACT_APP_FIREBASE_AUTH_DOMAIN= 5 | REACT_APP_FIREBASE_PROJECT_ID= 6 | 7 | -------------------------------------------------------------------------------- /.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 | # firebase 15 | /.firebase 16 | .firebaserc 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Hooks and Firebase Demo - Live Grocery List 2 | 3 | The Live Grocery List app is a demonstration of using React Hooks to manage app state that is backed by a Firebase firestore backend. This app demonstrates using building in React hooks as well as custom hooks. 4 | 5 | ## Setup 6 | 7 | This application uses Firebase services. Configuration required to connect to Firebase is defined in the [.env](.env) file (or .env.local) in the root of this repository. 8 | 9 | Before building or running the app, you must add your Firebase project configuration. Configuration can be obtained from *Project Settings* in your Firebase project at [https://console.firebase.google.com](https://console.firebase.google.com). 10 | 11 | ## Available Scripts 12 | 13 | In the project directory, you can run: 14 | 15 | `npm start` to run the app in development mode 16 | `npm test` to launch the test runner in interactive watch mode 17 | `npm run build` to bundle the app for production 18 | `npm run eject` to eject React Scripts dependency 19 | 20 | ## Live Demo 21 | 22 | Try a live version of this app at [https://fir-with-react-hooks.firebaseapp.com/](https://fir-with-react-hooks.firebaseapp.com/). 23 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase-with-react-hooks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "firebase": "^9.6.4", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.3.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Grocery List 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import * as FirestoreService from './services/firestore'; 4 | 5 | import CreateList from './scenes/CreateList/CreateList'; 6 | import JoinList from './scenes/JoinList/JoinList'; 7 | import EditList from './scenes/EditList/EditList'; 8 | import ErrorMessage from './components/ErrorMessage/ErrorMessage'; 9 | 10 | import useQueryString from './hooks/useQueryString' 11 | 12 | 13 | function App() { 14 | 15 | const [user, setUser] = useState(); 16 | const [groceryList, setGroceryList] = useState(); 17 | const [userId, setUserId] = useState(); 18 | const [error, setError] = useState(); 19 | 20 | // Use a custom hook to subscribe to the grocery list ID provided as a URL query parameter 21 | const [groceryListId, setGroceryListId] = useQueryString('listId'); 22 | 23 | // Use an effect to authenticate and load the grocery list from the database 24 | useEffect(() => { 25 | FirestoreService.authenticateAnonymously() 26 | .then(userCredential => { 27 | setUserId(userCredential.user.uid); 28 | if (groceryListId) { 29 | FirestoreService.getGroceryList(groceryListId) 30 | .then(groceryList => { 31 | if (groceryList.exists) { 32 | setError(null); 33 | setGroceryList(groceryList.data()); 34 | } else { 35 | setError('grocery-list-not-found'); 36 | setGroceryListId(); 37 | } 38 | }) 39 | .catch(() => setError('grocery-list-get-fail')); 40 | } 41 | }) 42 | .catch(() => setError('anonymous-auth-failed')); 43 | }, [groceryListId, setGroceryListId]); 44 | 45 | function onGroceryListCreate(groceryListId, userName) { 46 | setGroceryListId(groceryListId); 47 | setUser(userName); 48 | } 49 | 50 | function onCloseGroceryList() { 51 | setGroceryListId(); 52 | setGroceryList(); 53 | setUser(); 54 | } 55 | 56 | function onSelectUser(userName) { 57 | setUser(userName); 58 | FirestoreService.getGroceryList(groceryListId) 59 | .then(updatedGroceryList => setGroceryList(updatedGroceryList.data())) 60 | .catch(() => setError('grocery-list-get-fail')); 61 | } 62 | 63 | // render a scene based on the current state 64 | if (groceryList && user) { 65 | return ; 66 | } else if(groceryList) { 67 | return ( 68 |
69 | 70 | 71 |
72 | ); 73 | } 74 | return ( 75 |
76 | 77 | 78 |
79 | ); 80 | } 81 | 82 | export default App; 83 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/Our grocery list/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/ErrorMessage/ErrorMessage.css: -------------------------------------------------------------------------------- 1 | .error { 2 | font-family: sans-serif; 3 | font-weight: bold; 4 | color: #a33535; 5 | background-color: #e0caca; 6 | padding: 5px; 7 | text-align: center; 8 | } -------------------------------------------------------------------------------- /src/components/ErrorMessage/ErrorMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './ErrorMessage.css'; 3 | 4 | function ErrorMessage(props) { 5 | 6 | const { errorCode } = props; 7 | 8 | function getErrorMessage() { 9 | switch(errorCode) { 10 | case 'anonymous-auth-failed': 11 | return 'Anonymous authentication failed. Try again.' 12 | case 'grocery-list-not-found': 13 | return 'The grocery list could not be found. Try creating a new one.'; 14 | case 'grocery-list-get-fail': 15 | return 'Failed to retrieve the grocery list. Try again.'; 16 | case 'add-list-item-error': 17 | return 'Failed to add grocery item to list. Try again.'; 18 | case 'create-list-error': 19 | return 'Failed to create the grocery list. Try again.'; 20 | case 'add-user-to-list-error': 21 | return 'Failed to add user to the grocery list. Try again.'; 22 | case 'grocery-item-desc-req': 23 | return 'grocery item description required'; 24 | case 'duplicate-item-error': 25 | return 'grocery item on list already'; 26 | case 'user-name-required': 27 | return 'your name is required'; 28 | case 'grocery-list-item-get-fail': 29 | return 'failed to get grocery list items'; 30 | default: 31 | return 'Oops, something went wrong.'; 32 | } 33 | } 34 | 35 | return errorCode ?

{getErrorMessage()}

: null; 36 | }; 37 | 38 | export default ErrorMessage; -------------------------------------------------------------------------------- /src/hooks/useQueryString.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | // This custom hook keeps a state value in sync with a query parameter in the website's URL. 4 | // This allows the URL to be used to restore state on full-page reload or if the URL is shared/bookmarked. 5 | function useQueryString(key) { 6 | 7 | const [ paramValue, setParamValue ] = useState(getQueryParamValue(key)); 8 | 9 | // The useCallback hook is only called when one of its dependencies change. 10 | // In this case, we only want to update the browser URL if the query string needs to change. 11 | const onSetValue = useCallback( 12 | newValue => { 13 | setParamValue(newValue); 14 | updateQueryStringWithoutReload(newValue ? `${key}=${newValue}` : ''); 15 | }, 16 | [key, setParamValue] 17 | ); 18 | 19 | function getQueryParamValue(key) { 20 | return new URLSearchParams(window.location.search).get(key); 21 | } 22 | 23 | // update a query string without causing a browser reload 24 | function updateQueryStringWithoutReload(queryString) { 25 | const { protocol, host, pathname } = window.location; 26 | const newUrl = `${protocol}//${host}${pathname}?${queryString}`; 27 | window.history.pushState({ path: newUrl }, '', newUrl); 28 | } 29 | 30 | return [paramValue, onSetValue]; 31 | } 32 | 33 | export default useQueryString; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 20px; 3 | font-family: 'Sriracha', cursive; 4 | background: rgb(141, 185, 211); 5 | color: #003366; 6 | } 7 | 8 | a { 9 | color: #003366; 10 | } 11 | 12 | header { 13 | text-align: center; 14 | } 15 | 16 | footer { 17 | text-align: center; 18 | } 19 | 20 | h2 { 21 | margin-top: 5px; 22 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /src/scenes/CreateList/CreateList.css: -------------------------------------------------------------------------------- 1 | .create-container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: stretch; 6 | } 7 | 8 | .create-container div { 9 | max-width: 500px; 10 | text-align: center; 11 | } 12 | -------------------------------------------------------------------------------- /src/scenes/CreateList/CreateList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './CreateList.css'; 3 | import * as FirestoreService from '../../services/firestore'; 4 | import ErrorMessage from '../../components/ErrorMessage/ErrorMessage'; 5 | 6 | function CreateList(props) { 7 | 8 | const { onCreate, userId } = props; 9 | 10 | const [ error, setError ] = useState(); 11 | 12 | function createGroceryList(e) { 13 | e.preventDefault(); 14 | setError(null); 15 | 16 | const userName = document.createListForm.userName.value; 17 | if (!userName) { 18 | setError('user-name-required'); 19 | return; 20 | } 21 | 22 | FirestoreService.createGroceryList(userName, userId) 23 | .then(docRef => { 24 | onCreate(docRef.id, userName); 25 | }) 26 | .catch(reason => setError('create-list-error')); 27 | } 28 | 29 | return ( 30 |
31 |
32 |

Welcome to the Grocery List app!

33 |
34 |
35 |
36 |
37 |

38 |

39 | 40 |

41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | 48 | export default CreateList; -------------------------------------------------------------------------------- /src/scenes/EditList/AddItem/AddItem.css: -------------------------------------------------------------------------------- 1 | .button-group button { 2 | margin-right: 10px; 3 | font-size: 1.1rem; 4 | color: #003366; 5 | } -------------------------------------------------------------------------------- /src/scenes/EditList/AddItem/AddItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './AddItem.css'; 3 | import * as FirestoreService from '../../../services/firestore'; 4 | import ErrorMessage from '../../../components/ErrorMessage/ErrorMessage' 5 | 6 | 7 | function AddItem(props) { 8 | 9 | const { groceryListId, userId } = props; 10 | 11 | const [error, setError] = useState(''); 12 | 13 | function addItem(e) { 14 | e.preventDefault(); 15 | setError(null); 16 | 17 | const itemDesc = document.addItemForm.itemDesc.value; 18 | if (!itemDesc) { 19 | setError('grocery-item-desc-req'); 20 | return; 21 | } 22 | 23 | FirestoreService.addGroceryListItem(itemDesc, groceryListId, userId) 24 | .then(() => document.addItemForm.reset()) 25 | .catch(reason => { 26 | if (reason.message === 'duplicate-item-error') { 27 | setError(reason.message); 28 | } else { 29 | setError('add-list-item-error'); 30 | } 31 | }); 32 | } 33 | 34 | return ( 35 |
36 |

I want...

37 | 38 | 39 | 40 |
41 | ); 42 | } 43 | 44 | export default AddItem; -------------------------------------------------------------------------------- /src/scenes/EditList/EditList.css: -------------------------------------------------------------------------------- 1 | .edit-container { 2 | display: flex; 3 | justify-content: center; 4 | flex-wrap: wrap; 5 | } 6 | 7 | .add-item-column { 8 | min-width: 300px; 9 | width: 25%; 10 | background-color: rgb(247, 237, 202); 11 | padding: 20px; 12 | border-radius: 2%; 13 | margin: 5px; 14 | text-align: center; 15 | } 16 | 17 | .list-column { 18 | min-width: 300px; 19 | width: 25%; 20 | background-color: rgb(247, 237, 202); 21 | padding: 20px; 22 | border-radius: 2%; 23 | margin: 5px; 24 | text-align: center; 25 | } -------------------------------------------------------------------------------- /src/scenes/EditList/EditList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './EditList.css'; 3 | import AddItem from './AddItem/AddItem'; 4 | import ItemList from './ItemList/ItemList'; 5 | 6 | function EditList(props) { 7 | 8 | const { groceryListId, user, onCloseGroceryList, userId } = props; 9 | 10 | function onCreateListClick(e) { 11 | e.preventDefault(); 12 | onCloseGroceryList(); 13 | } 14 | 15 | return ( 16 |
17 |
18 |

Live Grocery List

19 |

Hi {user}!

20 |

Add items to the list. When someone else adds an item it will instantly appear on the list.

21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 | 33 |
34 | ); 35 | } 36 | 37 | export default EditList; -------------------------------------------------------------------------------- /src/scenes/EditList/ItemList/ItemList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import * as FirestoreService from '../../../services/firestore'; 3 | import ErrorMessage from '../../../components/ErrorMessage/ErrorMessage'; 4 | 5 | function ItemList(props) { 6 | 7 | const { groceryListId } = props; 8 | 9 | const [ groceryItems, setGroceryItems ] = useState([]); 10 | const [ error, setError ] = useState(); 11 | 12 | // Use an effect hook to subscribe to the grocery list item stream and 13 | // automatically unsubscribe when the component unmounts. 14 | useEffect(() => { 15 | const unsubscribe = FirestoreService.streamGroceryListItems(groceryListId, 16 | (querySnapshot) => { 17 | const updatedGroceryItems = 18 | querySnapshot.docs.map(docSnapshot => docSnapshot.data()); 19 | setGroceryItems(updatedGroceryItems); 20 | }, 21 | (error) => setError('grocery-list-item-get-fail') 22 | ); 23 | return unsubscribe; 24 | }, [groceryListId, setGroceryItems]); 25 | 26 | const groceryItemElements = groceryItems 27 | .map((groceryItem, i) =>
{groceryItem.name}
); 28 | 29 | return ( 30 |
31 | 32 |
{groceryItemElements}
33 |
34 | ); 35 | } 36 | 37 | export default ItemList; -------------------------------------------------------------------------------- /src/scenes/JoinList/JoinList.css: -------------------------------------------------------------------------------- 1 | .join-container { 2 | display: flex; 3 | justify-content: center; 4 | flex-wrap: wrap; 5 | } 6 | 7 | .join-container div { 8 | text-align: center; 9 | } -------------------------------------------------------------------------------- /src/scenes/JoinList/JoinList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './JoinList.css'; 3 | import ErrorMessage from '../../components/ErrorMessage/ErrorMessage'; 4 | import * as FirestoreService from '../../services/firestore'; 5 | 6 | function JoinList(props) { 7 | 8 | const { users, groceryListId, onSelectUser, onCloseGroceryList, userId } = props; 9 | 10 | const [ error, setError ] = useState(); 11 | 12 | function addExistingUser(e) { 13 | e.preventDefault(); 14 | onSelectUser(e.target.innerText); 15 | } 16 | 17 | function getUserButtonList() { 18 | const buttonList = users.map(user => ); 19 | return
{buttonList}
; 20 | } 21 | 22 | function addNewUser(e) { 23 | e.preventDefault(); 24 | setError(null); 25 | 26 | const userName = document.addUserToListForm.name.value; 27 | if (!userName) { 28 | setError('user-name-required'); 29 | return; 30 | } 31 | 32 | if (users.find(user => user.name === userName)) { 33 | onSelectUser(userName); 34 | } else { 35 | FirestoreService.addUserToGroceryList(userName, groceryListId, userId) 36 | .then(() => onSelectUser(userName)) 37 | .catch(() => setError('add-user-to-list-error')); 38 | } 39 | } 40 | 41 | function onCreateListClick(e) { 42 | e.preventDefault(); 43 | onCloseGroceryList(); 44 | } 45 | 46 | return ( 47 |
48 |
49 |

Welcome to the Grocery List app!

50 |
51 |
52 |
53 |
54 |

Select your name if you previously joined the list...

55 | {getUserButtonList()} 56 |

...or enter your name to join the list...

57 |

58 | 59 | 60 |

61 | 62 |

...or create a new grocery list

63 |
64 |
65 |
66 |
67 | ); 68 | } 69 | 70 | export default JoinList; -------------------------------------------------------------------------------- /src/services/firestore.js: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase/app"; 2 | import { 3 | getFirestore, 4 | query, 5 | orderBy, 6 | onSnapshot, 7 | collection, 8 | getDoc, 9 | getDocs, 10 | addDoc, 11 | updateDoc, 12 | doc, 13 | serverTimestamp, 14 | arrayUnion 15 | } from "firebase/firestore"; 16 | import { getAuth, signInAnonymously} from "firebase/auth"; 17 | 18 | const firebaseConfig = { 19 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 20 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 21 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID 22 | }; 23 | 24 | const app = initializeApp(firebaseConfig); 25 | const db = getFirestore(app) 26 | 27 | export const authenticateAnonymously = () => { 28 | return signInAnonymously(getAuth(app)); 29 | }; 30 | 31 | export const createGroceryList = (userName, userId) => { 32 | const groceriesColRef = collection(db, 'groceryLists') 33 | return addDoc(groceriesColRef, { 34 | created: serverTimestamp(), 35 | createdBy: userId, 36 | users: [{ 37 | userId: userId, 38 | name: userName 39 | }] 40 | }); 41 | }; 42 | 43 | export const getGroceryList = (groceryListId) => { 44 | const groceryDocRef = doc(db, 'groceryLists', groceryListId) 45 | return getDoc(groceryDocRef); 46 | }; 47 | 48 | export const getGroceryListItems = (groceryListId) => { 49 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items') 50 | return getDocs(itemsColRef) 51 | } 52 | 53 | export const streamGroceryListItems = (groceryListId, snapshot, error) => { 54 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items') 55 | const itemsQuery = query(itemsColRef, orderBy('created')) 56 | return onSnapshot(itemsQuery, snapshot, error); 57 | }; 58 | 59 | export const addUserToGroceryList = (userName, groceryListId, userId) => { 60 | const groceryDocRef = doc(db, 'groceryLists', groceryListId) 61 | return updateDoc(groceryDocRef, { 62 | users: arrayUnion({ 63 | userId: userId, 64 | name: userName 65 | }) 66 | }); 67 | }; 68 | 69 | export const addGroceryListItem = (item, groceryListId, userId) => { 70 | return getGroceryListItems(groceryListId) 71 | .then(querySnapshot => querySnapshot.docs) 72 | .then(groceryListItems => groceryListItems.find(groceryListItem => groceryListItem.data().name.toLowerCase() === item.toLowerCase())) 73 | .then( (matchingItem) => { 74 | if (!matchingItem) { 75 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items') 76 | return addDoc(itemsColRef, { 77 | name: item, 78 | created: serverTimestamp(), 79 | createdBy: userId 80 | }); 81 | } 82 | throw new Error('duplicate-item-error'); 83 | }); 84 | }; -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------