Before we start
If you prefer to watch instead of reading..
Also, if you want to read about creating the exact same application in Angular, you can read here.
What are we building
I saw this challenge in FrontendMentor for a todo application. I thought to make it a bit more interesting by adding user login and also, store the data on Backend.
We will use ReactJS and Material UI for the Frontend and Firebase for storing the data and Authentication.
We will also add CI/CD with Github Actions and host the application on Firebase.
This is what the final version looks like.
ReactJS setup
We will use Create React App for creating a basic React application setup. We will also use Typescript.
npx create-react-app react-firebase-todo --template typescript
cd react-firebase-todo
npm start
Application will open on http://localhost:3000
Material Setup
Material UI has two style engine options. By default it uses Emotion as the style engine. We will follow the same.
npm install @mui/material @emotion/react @emotion/styled
This is enough to use any of the Material components. But, if we want to customise themes, we need to use ThemeProvider. We need to also add base Material styles.
In our application, we have Dark and Light theme and we should be able to switch between themes.
Whenever the theme is changed, we need to inform the same across the application. For all these, we will create a custom Theme Provider on top of Material UI's theme provider.
First, we will create two themes. To create theme, we can use createTheme from Material. We will create two themes.
src/theme.ts
import { createTheme } from '@mui/material/styles';
import { green, purple, pink } from '@mui/material/colors';
export enum Theme {
DARK = 'dark',
LIGHT = 'light',
}
const lightTheme = createTheme({
palette: {
primary: {
main: purple[500],
},
secondary: {
main: green[500],
},
},
});
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: 'hsl(235, 24%, 19%)',
},
secondary: {
main: pink[500],
},
},
});
const themes = {
[Theme.DARK]: darkTheme,
[Theme.LIGHT]: lightTheme,
};
export const getTheme = (theme: Theme) => {
return themes[theme];
};
Here, for the dark theme, we are giving mode as dark, so that, the dark mode styles will be applied.
Now, we will create the custom theme provider. We will also save the theme name to sessionStorage so that, we can persist the selected theme on page refresh. By default, we are setting the theme as Light.
src/providers/theme.tsx
import React, { createContext, FC, ReactNode, useState } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { Theme, getTheme } from '../theme';
type ThemeContextType = {
currentTheme: Theme;
setTheme: ((theme: Theme) => void) | null;
};
export const AppThemeContext = createContext<ThemeContextType>({
currentTheme: Theme.LIGHT,
setTheme: null,
});
type Props = {
children: ReactNode;
};
export const AppThemeProvider: FC<Props> = ({ children }) => {
const addOrRemoveBodyClass = (theme: Theme) => {
if (theme === Theme.DARK) {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
};
const getCurrentTheme = () => {
const currentTheme = sessionStorage.getItem('theme')
? (sessionStorage.getItem('theme') as Theme)
: Theme.LIGHT;
addOrRemoveBodyClass(currentTheme);
return currentTheme;
};
const currentTheme = getCurrentTheme();
const [theme, _setCurrentTheme] = useState<Theme>(currentTheme);
const setTheme = (theme: Theme) => {
sessionStorage.setItem('theme', theme);
addOrRemoveBodyClass(theme);
_setCurrentTheme(theme);
};
const value = {
currentTheme: theme,
setTheme,
};
return (
<AppThemeContext.Provider value={value}>
<ThemeProvider theme={getTheme(theme)}>{children}</ThemeProvider>
</AppThemeContext.Provider>
);
};
Here, we are also adding a class to document body, if the theme is dark. This is because, we want to give a different background image on dark theme.
Lets add the style in index.css
#root {
background-image: url('./images/bg-desktop-light.jpg');
background-repeat: no-repeat;
}
@media screen and (max-width: 375px) {
#root {
background-image: url('./images/bg-mobile-light.jpg');
}
.dark-theme #root {
background-image: url('./images/bg-mobile-dark.jpg');
}
}
.dark-theme #root {
background-image: url('./images/bg-desktop-dark.jpg');
background-repeat: no-repeat;
}
root is the id of the root element for our React application.
And, for the material basic styles, we will add the CssBaseLine
src/index.tsx
import CssBaseline from '@mui/material/CssBaseline';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<AppThemeProvider>
<CssBaseline />
<App />
</AuthProvider>
</React.StrictMode>
);
React Router Setup
First, we need to install React Router
npm install react-router-dom
We need two pages. One for Login and one for Home page to display the todo items.
Lets create two simple React components and we will create a custom provider for this also.
We can keep the header same in both pages. Only the content area need to change based on the route. So, we can keep one parent component for the layout and we will render the route component inside the layout component. We will keep App component as the Layout component.
src/providers/route.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from '../App';
import { HomePage } from '../components/home-page';
import { LoginPage } from '../components/login-page';
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
path: '/',
element: <HomePage />,
},
{
path: '/login',
element: <LoginPage />,
},
],
},
]);
export const AppRouterProvider = () => {
return <RouterProvider router={router} />;
};
Instead of App component, we will render AppRouterProvider in root level.
src/index.tsx
root.render(
<React.StrictMode>
<AppThemeProvider>
<CssBaseline />
<AppRouterProvider />
</AppThemeProvider>
</React.StrictMode>
);
And, how do we tell React Router where to render the child component inside App component? Using the Outlet.
src/App.tsx
import { Outlet } from 'react-router-dom';
function App() {
return (
<div>
<header>
</header>
<main>
<Outlet />
</main>
</div>
);
}
export default App;
Firebase Setup
We will go to Firebase Console and add a new project.
Copy the Firebase config. Create firebase.ts file in src folder and copy the firebase config to that.
Also, We will need to enable Authentication and FireStore. In Authentication, we will enable both Google and Email/Password based Login.
In FireStore, we will create a collection "todos". For a Todo, we will need a title, and a status to know if its completed or not, and also, we need to associate with a user.
Lets create a document under the collection.
Now, we can try to try to use the same in our application.
First, we will install Firebase package.
npm install firebase
We will need to get the Firestore database instance. And, this database instance is needed in all components which need the data. So, we will export from where we create.
src/firebase.ts
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
...
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
Now, in our HomePage component, we can read the collection to get the todos.
src/components/home-page/home-page.tsx
import React, { FC, useContext, useEffect, useState } from 'react';
import { db } from '../../firebase';
import {
collection,
onSnapshot,
doc,
where,
query,
} from 'firebase/firestore';
export const HomePage = () => {
const [todos, setTodos] = useState<Todo[] | null>(null);
useEffect(() => {
const q = query(
collection(db, 'todos')
);
const subscribeToTodos = () => {
return onSnapshot(q, (querySnapshot) => {
const todos: Todo[] = [];
querySnapshot.forEach((doc) => {
const todoItem = {
id: doc.id,
...doc.data(),
};
todos.push(todoItem as Todo);
});
setTodos(todos);
});
};
const unsub = subscribeToTodos();
return unsub;
}
}, []);
}
onSnapshot callback will be triggered whenever there is a data change.
Now, we need to setup the Authentication also. We need the authentication information also to be available across the application. So, we will create Auth Provider and read/write user data with sessionStorage.
And, we will use the AuthProvider on top level so that, all the components will have access to logged in user.
Now, in LoginPage component, we need to do both Google and Email/Password login. First we will get the Google provider and also the auth instance.
src/components/login-page/login-page.tsx
import {
GoogleAuthProvider,
getAuth,
signInWithPopup,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
} from 'firebase/auth';
const provider = new GoogleAuthProvider();
const auth = getAuth();
export const LoginPage = () => {
const signInWithGoogle = () => {
signInWithPopup(auth, provider)
.then((result) => {
// set user to context
})
};
const signUp = () => {
createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// set user to context
});
};
const loginWithEmailAndPassword = () => {
signInWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// set user to context
});
};
}
Now, we can change query in home component to get only the todos of the logged in user.
We will change the query like the following
src/components/home-page/home-page.tsx
const q = query(
collection(db, 'todos'),
where('userId', '==', user.uid)
);
Delete Todo
To delete a todo, we can use the deleteDoc method. We will add a Delete button for each todo, and on click of the button, we will call the deleteTodo method.
src/components/home-page/home-page.tsx
import {
doc,
deleteDoc,
} from 'firebase/firestore';
export const HomePage = () => {
const deleteTodo = (todo: Todo) => {
if (todo.id) {
deleteDoc(doc(db, 'todos', todo.id));
}
};
}
Set Todo as Completed
This is an update operation. We need to change the value of isCompleted property of Todo to true.
We will be adding RadioButton on each Todo item and on press of the radio button, we will call handleRadioCheck method.
src/components/home-page/home-page.tsx
import {
updateDoc,
doc,
} from 'firebase/firestore';
const handleRadioCheck = (todo: Todo) => {
if (todo.id) {
const docReference = doc(db, 'todos', todo.id);
updateDoc(docReference, {
isCompleted: true,
});
}
};
Filter Todo Items
We want to filter Todo items based on status, All, Active, or Completed. We will create a FilterState and use the useState to set the active filter state. We can. use this value to filter the todo items.
src/components/home-page/home-page.tsx
enum FilterState {
ALL = 'All',
ACTIVE = 'Active',
COMPLETED = 'Completed',
}
export const HomePage = () => {
const [activeFilter, setActiveFilter] = useState<FilterState>(
FilterState.ALL
);
const filteredTodos =
activeFilter === FilterState.ALL
? todos
: todos?.filter((todo) => {
const filterCondition =
activeFilter === FilterState.ACTIVE ? false : true;
return todo.isCompleted === filterCondition;
});
}
And, we will be rendering the filteredTodos instead of todos.
Add Todo
We will create an AddTodo Component. We just need a text field. On Enter Key Press, we want to create a new Todo item.
src/components/add-todo/add-todo.tsx
import { collection, addDoc } from 'firebase/firestore';
import TextField from '@mui/material/TextField';
import { db } from '../../firebase';
export const AddTodo = () => {
const onKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const inputValue = (event.target as HTMLInputElement).value;
if (inputValue) {
addDoc(collection(db, 'todos'), {
title: inputValue,
isCompleted: false,
userId: user?.uid,
});
}
}
};
return (
...
<TextField
id="input-with-sx"
fullWidth
label="Add Todo"
variant="standard"
onKeyPress={onKeyPress}
/>
...
)
}
Protected Routes
Since we are showing todo items of the logged in user, we don't want the home page to be accessible to user who is not logged in. We want to check if user is available in Auth Context. If not, redirect back to login page. We will make it a generic component as this is a common feature.
We will create a ProtectedRoute component for adding the above check.
src/components/protected-route/protected-route.tsx
import React, { FC, ReactNode, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../../providers/auth';
type Props = {
children: ReactNode;
};
export const ProtectedRoute: FC<Props> = ({ children }) => {
const { user } = useContext(AuthContext);
if (!user) {
return <Navigate to="/login" replace={true} />;
}
return <>{children}</>;
};
And, our Router will be updated to use this ProtectedRoute component.
src/providers/route.tsx
import { ProtectedRoute } from '../components/protected-route';
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
path: '/',
element: (
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
),
},
{
path: '/login',
element: <LoginPage />,
},
],
},
]);
Firebase Hosting
To deploy to Firebase from the command prompt, we need Firebase CLI.
npm install -g firebase-tools
After that, we can initialise a Firebase project, by running
firebase init
It will ask some questions.
We can select Github Actions also. So, Firebase will generate two files under .github/workflows directory. This is the default path for Github Actions. Github will automatically detect the workflow files and create the Actions.
Now, we can deploy using firebase deploy
Github Code Actions - CI/CD
Firebase has generated two Action workflows. One for deploying to Firebase App whenever code is pushed to main branch. Other one is two run whenever a pull request is created.
Now, if we push to main branch, build and deploy action will be triggered automatically.
Same way, if we create a pull request, build and preview action will be triggered.
Firebase will also create a preview url and comment on Pull Request. This way, we can preview our changes before merging to the main branch.
Unit Test
We are using React Testing Library and Jest for Unit Testing React components. These are already setup in Create React App.
Since App component needs to be aware of Outlet in the testing, we will create a dummy Routing setup in the test file.
src/App.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';
const DummyComp = () => <span>Hello</span>;
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
path: '/',
element: <DummyComp />,
},
],
},
]);
const renderApp = () => {
render(<RouterProvider router={router} />);
};
test('renders learn react link', () => {
renderApp();
const loginButton = screen.getByText('Login');
expect(loginButton).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
We can use different selectors. Here, we are trying to get a button by Text and checking its presence.
Now, let's look into how to test a user event. We will add a test for AddTodo component.
We can use userEvent from React Testing Library
src/components/add-todo/add-todo.test.tsx
import React, { ReactElement, ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import { collection, addDoc } from 'firebase/firestore';
import { AddTodo } from './add-todo';
import userEvent from '@testing-library/user-event';
const setup = (jsx: ReactElement) => {
return {
user: userEvent,
...render(jsx),
};
};
jest.mock('firebase/firestore');
describe('<AddTodo />', () => {
(addDoc as jest.Mock).mockImplementation(jest.fn());
it('should trigger addDoc on enter keypress', async () => {
const { user } = setup(<AddTodo />);
const inputEl = screen.getByLabelText('Add Todo');
await user.type(inputEl, 'hello{Enter}');
expect(addDoc).toHaveBeenCalledWith(undefined, {
title: 'hello',
isCompleted: false,
userId: undefined,
});
});
});
We are also mocking addDoc method using jest.mock as we don't want to make the real Firebase addDoc call. Instead, we just want to know if the addDoc is called with expected values.
Conclusion
With this, we are able to Login, Add Todo, Delete Todo, Mark Todo as Completed, Filter Todos, Logout and also setup CI/CD and added Unit Tests.
You can get full code here.
Please let me know your thoughts on this.
Top comments (0)