DEV Community

Cover image for How to build the most beautiful Todolist with React Native and Socket.io 🎉
Nevo David Subscriber for novu

Posted on • Originally published at novu.co

How to build the most beautiful Todolist with React Native and Socket.io 🎉

What is this article about?

Todolist is a simple task list where you mark everything you need to do and the status of it "Finished / Not Finished".

In this article, you'll learn how to build a to-do list application that allows you to sign in, create and delete a to-do, and add comments to each to-do using React Native and Socket.io.

Dog

Why Socket.io?

If you are reading this, you have probably wondered - I can do it with a Restful API. So why do I need to use Socket.io?
We want to make a todo list where the user can create a todo list for other users and let them see the status online without refreshing the page.

Socket.io is a highly performant JavaScript library that allows us to create real-time, bi-directional communication between web browsers and a Node.js server. It follows the WebSocket protocol and provides better functionalities, such as fallback to HTTP long-polling or automatic reconnection, which enables us to build efficient real-time applications.

Novu - the first open-source notification infrastructure

Just a quick background about us. Novu is the first open-source notification infrastructure. We basically help to manage all the product notifications. It can be In-App (the bell icon like you have in Facebook - Websockets), Emails, SMSs and so on.

Novu

I would be super happy if you could give us a star! And let me also know in the comments ❤️
https://github.com/novuhq/novu

How to connect React Native to a Socket.io server

Here, you'll learn how to connect the to-do list application to a Socket.io server. In this guide, I’ll be using Expo - a tool that provides an easier way of building React Native applications.

Creating a React Native app with Expo

Expo saves us from the complex configurations required to create a native application with the React Native CLI, making it the easiest and fastest way to build and publish React Native apps.

Ensure you have the Expo CLI, Node.js, and Git installed on your computer. Then, create the project folder and an Expo React Native app by running the code below.



mkdir todolist-app
cd todolist-app
expo init app


Enter fullscreen mode Exit fullscreen mode

Expo allows us to create native applications using the Managed or Bare Workflow. We'll use the blank Managed Workflow in this tutorial.



? Choose a template: › - Use arrow-keys. Return to submit.
    ----- Managed workflow -----
❯   blank               a minimal app as clean as an empty canvas
    blank (TypeScript)  same as blank but with TypeScript configuration
    tabs (TypeScript)   several example screens and tabs using react-navigation and TypeScript
    ----- Bare workflow -----
    minimal             bare and minimal, just the essentials to get you started


Enter fullscreen mode Exit fullscreen mode

Install Socket.io Client API to the React Native app.



cd app
expo install socket.io-client


Enter fullscreen mode Exit fullscreen mode

Create a socket.js file within a utils folder.



mkdir utils
touch socket.js


Enter fullscreen mode Exit fullscreen mode

Then, copy the code below into the socket.js file.



import { io } from "socket.io-client";

const socket = io.connect("http://localhost:4000");
export default socket;


Enter fullscreen mode Exit fullscreen mode

The code snippet above creates a real-time connection to the server hosted at that URL. (We'll set up the server in the upcoming section).

Create a styles.js file within the utils folder and copy the code below into the file. It contains all the styling for the application.



import { StyleSheet } from "react-native";

export const styles = StyleSheet.create({
    screen: {
        flex: 1,
        backgroundColor: "#fff",
        padding: 10,
    },
    header: {
        padding: 10,
        justifyContent: "space-between",
        flexDirection: "row",
        marginBottom: 20,
    },
    heading: {
        fontSize: 24,
        fontWeight: "bold",
    },
    container: {
        padding: 15,
    },
    loginScreen: {
        flex: 1,
    },
    loginContainer: {
        flex: 1,
        padding: 10,
        flexDirection: "column",
        justifyContent: "center",
    },
    textInput: {
        borderWidth: 1,
        width: "100%",
        padding: 12,
        marginBottom: 10,
    },
    loginButton: {
        width: 150,
        backgroundColor: "#0D4C92",
        padding: 15,
    },
    todoContainer: {
        flexDirection: "row",
        justifyContent: "space-between",
        backgroundColor: "#CDF0EA",
        padding: 15,
        borderRadius: 10,
        marginBottom: 10,
    },
    todoTitle: {
        fontWeight: "bold",
        fontSize: 18,
        marginBottom: 8,
    },
    subTitle: {
        opacity: 0.6,
    },
    form: {
        flexDirection: "row",
        marginBottom: 40,
    },
    input: {
        borderWidth: 1,
        padding: 12,
        flex: 1,
        justifyContent: "center",
    },
    modalScreen: {
        backgroundColor: "#fff",
        flex: 1,
        padding: 10,
        alignItems: "center",
    },
    textInput: {
        borderWidth: 1,
        padding: 10,
        width: "95%",
        marginBottom: 15,
    },
    modalButton: {
        backgroundColor: "#0D4C92",
        padding: 10,
    },
    buttonText: {
        fontSize: 18,
        textAlign: "center",
        color: "#fff",
    },
    comment: { marginBottom: 20 },
    message: {
        padding: 15,
        backgroundColor: "#CDF0EA",
        width: "80%",
        borderRadius: 10,
    },
});


Enter fullscreen mode Exit fullscreen mode

Install React Navigation and its dependencies. React Navigation allows us to move from one screen to another within a React Native application.



npm install @react-navigation/native
npx expo install react-native-screens react-native-safe-area-context


Enter fullscreen mode Exit fullscreen mode

Setting up the Node.js server

Here, I will guide you through creating the Socket.io Node.js server for real-time communication.

Create a server folder within the project folder.



cd todolist-app
mkdir server


Enter fullscreen mode Exit fullscreen mode

Navigate into the server folder and create a package.json file.



cd server & npm init -y


Enter fullscreen mode Exit fullscreen mode

Install Express.js, CORS, Nodemon, and Socket.io Server API.



npm install express cors nodemon socket.io


Enter fullscreen mode Exit fullscreen mode

Express is a fast, minimalist framework that provides several features for building web applications in Node.js. CORS is a Node.js package that allows communication between different domains.

Nodemon is a Node.js tool that automatically restarts the server after detecting file changes, and Socket.io allows us to configure a real-time connection on the server.

Create an index.js file - the entry point to the Node.js server.



touch index.js


Enter fullscreen mode Exit fullscreen mode

Set up a simple Node.js server using Express.js. The code snippet below returns a JSON object when you visit the http://localhost:4000/api in your browser.



//👇🏻 index.js
const express = require("express");
const app = express();
const PORT = 4000;

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.get("/api", (req, res) => {
    res.json({
        message: "Hello world",
    });
});

app.listen(PORT, () => {
    console.log(`Server listening on ${PORT}`);
});


Enter fullscreen mode Exit fullscreen mode

Next, add Socket.io to the project to create a real-time connection. Before the app.get() block, copy the code below.



//👇🏻 New imports
.....
const socketIO = require('socket.io')(http, {
    cors: {
        origin: "http://localhost:3000"
    }
});

//👇🏻 Add this before the app.get() block
socketIO.on('connection', (socket) => {
    console.log(`⚡: ${socket.id} user just connected!`);

    socket.on('disconnect', () => {
      socket.disconnect()
      console.log('🔥: A user disconnected');
    });
});


Enter fullscreen mode Exit fullscreen mode

From the code snippet above, the socket.io("connection") function establishes a connection with the React app, creates a unique ID for each socket, and logs the ID to the console whenever you refresh the app.

When you refresh or close the app, the socket fires the disconnect event showing that a user has disconnected from the socket.

Configure Nodemon by adding the start command to the list of scripts in the package.json file. The code snippet below starts the server using Nodemon.



//👇🏻 In server/package.json

"scripts": {
    "test": "echo \\"Error: no test specified\\" && exit 1",
    "start": "nodemon index.js"
  },


Enter fullscreen mode Exit fullscreen mode

You can now run the server with Nodemon by using the command below.



npm start


Enter fullscreen mode Exit fullscreen mode

Building the app user interface

In this section, we'll create the user interface for the to-do list application to enable users to sign in to the application, create and delete to-dos, and add comments to each to-do.

interface

First, let's set up React Navigation.

Create a screens folder within the app folder, and add the Home, Login, and Comments components. Render a "Hello World" text within them.



mkdir screens
cd screens
touch Home.js Login.js Comments.js


Enter fullscreen mode Exit fullscreen mode

Copy the code below into the App.js file within the app folder.



//👇🏻 the app components
import Home from "./screens/Home";
import Comments from "./screens/Comments";
import Login from "./screens/Login";

//👇🏻 React Navigation configurations
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";

const Stack = createNativeStackNavigator();

export default function App() {
    return (
        <NavigationContainer>
            <Stack.Navigator>
                <Stack.Screen
                    name='Login'
                    component={Login}
                    options={{ headerShown: false }}
                />
                <Stack.Screen
                    name='Home'
                    component={Home}
                    options={{ headerShown: false }}
                />
                <Stack.Screen name='Comments' component={Comments} />
            </Stack.Navigator>
        </NavigationContainer>
    );
}


Enter fullscreen mode Exit fullscreen mode

The Login screen

Copy the code below into the Login.js file.



import {
    View,
    Text,
    SafeAreaView,
    StyleSheet,
    TextInput,
    Pressable,
} from "react-native";

import React, { useState } from "react";

const Login = ({ navigation }) => {
    const [username, setUsername] = useState("");

    const handleLogin = () => {
        if (username.trim()) {
            console.log({ username });
        } else {
            Alert.alert("Username is required.");
        }
    };

    return (
        <SafeAreaView style={styles.loginScreen}>
            <View style={styles.loginContainer}>
                <Text
                    style={{
                        fontSize: 24,
                        fontWeight: "bold",
                        marginBottom: 15,
                        textAlign: "center",
                    }}
                >
                    Login
                </Text>
                <View style={{ width: "100%" }}>
                    <TextInput
                        style={styles.textInput}
                        value={username}
                        onChangeText={(value) => setUsername(value)}
                    />
                </View>
                <Pressable onPress={handleLogin} style={styles.loginButton}>
                    <View>
                        <Text style={{ color: "#fff", textAlign: "center", fontSize: 16 }}>
                            SIGN IN
                        </Text>
                    </View>
                </Pressable>
            </View>
        </SafeAreaView>
    );
};

export default Login;


Enter fullscreen mode Exit fullscreen mode

The code snippet accepts the username from the user and logs it on the console.

Next, update the code and save the username using Async Storage for easy identification.

Async Storage is a React Native package used to store string data in native applications. It is similar to the local storage on the web and can be used to store tokens and data in string format.

Run the code below to install Async Storage.



expo install @react-native-async-storage/async-storage


Enter fullscreen mode Exit fullscreen mode

Update the handleLogin function to save the username via AsyncStorage.



import AsyncStorage from "@react-native-async-storage/async-storage";

const storeUsername = async () => {
    try {
        await AsyncStorage.setItem("username", username);
        navigation.navigate("Home");
    } catch (e) {
        Alert.alert("Error! While saving username");
    }
};

const handleLogin = () => {
    if (username.trim()) {
        //👇🏻 calls AsyncStorage function
        storeUsername();
    } else {
        Alert.alert("Username is required.");
    }
};


Enter fullscreen mode Exit fullscreen mode

The Home screen

Update the Home.js file to contain the code snippet below:



import { SafeAreaView, Text, StyleSheet, View, FlatList } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react";
import Todo from "./Todo";
import ShowModal from "./ShowModal";

const Home = () => {
    const [visible, setVisible] = useState(false);

//👇🏻 demo to-do lists
    const [data, setData] = useState([
        { _id: "1", title: "Hello World", comments: [] },
        { _id: "2", title: "Hello 2", comments: [] },
    ]);

    return (
        <SafeAreaView style={styles.screen}>
            <View style={styles.header}>
                <Text style={styles.heading}>Todos</Text>
                <Ionicons
                    name='create-outline'
                    size={30}
                    color='black'
                    onPress={() => setVisible(!visible)}
                />
            </View>
            <View style={styles.container}>
                <FlatList
                    data={data}
                    keyExtractor={(item) => item._id}
                    renderItem={({ item }) => <Todo item={item} />}
                />
            </View>
            <ShowModal setVisible={setVisible} visible={visible} />
        </SafeAreaView>
    );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

From the code snippet above, we imported two components, Todo, and ShowModal as sub-components within the Home component. Next, let's create the Todo and ShowModal components.



touch Todo.js ShowModal.js


Enter fullscreen mode Exit fullscreen mode

Update the Todo.js file to contain the code below. It describes the layout for each to-do.



import { View, Text, StyleSheet } from "react-native";
import { React } from "react";
import { AntDesign } from "@expo/vector-icons";

const Todo = ({ item }) => {
    return (
        <View style={styles.todoContainer}>
            <View>
                <Text style={styles.todoTitle}>{item.title}</Text>
                <Text style={styles.subTitle}>View comments</Text>
            </View>
            <View>
                <AntDesign name='delete' size={24} color='red' />
            </View>
        </View>
    );
};

export default Todo;


Enter fullscreen mode Exit fullscreen mode

Update the ShowModal.js file to contain the code below:



import {
    Modal,
    View,
    Text,
    StyleSheet,
    SafeAreaView,
    TextInput,
    Pressable,
} from "react-native";
import React, { useState } from "react";

const ShowModal = ({ setVisible, visible }) => {
    const [input, setInput] = useState("");

    const handleSubmit = () => {
        if (input.trim()) {
            console.log({ input });
            setVisible(!visible);
        }
    };

    return (
        <Modal
            animationType='slide'
            transparent={true}
            visible={visible}
            onRequestClose={() => {
                Alert.alert("Modal has been closed.");
                setVisible(!visible);
            }}
        >
            <SafeAreaView style={styles.modalScreen}>
                <TextInput
                    style={styles.textInput}
                    value={input}
                    onChangeText={(value) => setInput(value)}
                />

                <Pressable onPress={handleSubmit} style={styles.modalButton}>
                    <View>
                        <Text style={styles.buttonText}>Add Todo</Text>
                    </View>
                </Pressable>
            </SafeAreaView>
        </Modal>
    );
};

export default ShowModal;


Enter fullscreen mode Exit fullscreen mode

The code snippet above represents the modal that pops up when you press the icon for creating a new to-do.

Gif2

The Comments screen

Copy the code snippet below into the Comments.js file.



import React, { useLayoutEffect, useState } from "react";
import { View, StyleSheet, TextInput, Button, FlatList } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import CommentUI from "./CommentUI";

const Comments = ({ navigation, route }) => {
    const [comment, setComment] = useState("");
    const [commentsList, setCommentsList] = useState([
        {
            id: "1",
            title: "Thank you",
            user: "David",
        },
        {
            id: "2",
            title: "All right",
            user: "David",
        },
    ]);
    const [user, setUser] = useState("");

    // fetches the username from AsyncStorage
    const getUsername = async () => {
        try {
            const username = await AsyncStorage.getItem("username");
            if (username !== null) {
                setUser(username);
            }
        } catch (err) {
            console.error(err);
        }
    };

    // runs on page load
    useLayoutEffect(() => {
        getUsername();
    }, []);

    // logs the comment details to the console
    const addComment = () => console.log({ comment, user });

    return (
        <View style={styles.screen}>
            <View style={styles.form}>
                <TextInput
                    style={styles.input}
                    value={comment}
                    onChangeText={(value) => setComment(value)}
                    multiline={true}
                />
                <Button title='Post Comment' onPress={addComment} />
            </View>

            <View>
                <FlatList
                    data={commentsList}
                    keyExtractor={(item) => item.id}
                    renderItem={({ item }) => <CommentUI item={item} />}
                />
            </View>
        </View>
    );
};

export default Comments;


Enter fullscreen mode Exit fullscreen mode

The code snippet above contains a sub-component, CommentUI - which represents the layout for each comment.

Update the CommentUI component as below:



import { View, Text, StyleSheet } from "react-native";
import React from "react";

const CommentUI = ({ item }) => {
    return (
        <View style={styles.comment}>
            <View style={styles.message}>
                <Text style={{ fontSize: 16 }}>{item.title}</Text>
            </View>

            <View>
                <Text>{item.user}</Text>
            </View>
        </View>
    );
};

export default CommentUI;


Enter fullscreen mode Exit fullscreen mode

Image description

Sending real-time data via Socket.io

In this section, you'll learn how to send data between the React Native application and a Socket.io server.

How to create a new to-do

Import socket from the socket.js file into the ShowModal.js file.



import socket from "../utils/socket";


Enter fullscreen mode Exit fullscreen mode

Update the handleSubmit function to send the new to-do to the server.



//👇🏻 Within ShowModal.js
const handleSubmit = () => {
    if (input.trim()) {
        //👇🏻 sends the input to the server
        socket.emit("addTodo", input);
        setVisible(!visible);
    }
};


Enter fullscreen mode Exit fullscreen mode

Create a listener to the addTodo event on the server that adds the to-do to an array on the backend.



//👇🏻 array of todos
const todoList = [];

//👇🏻 function that generates a random string as ID
const generateID = () => Math.random().toString(36).substring(2, 10);

socketIO.on("connection", (socket) => {
    console.log(`⚡: ${socket.id} user just connected!`);

    //👇🏻 listener to the addTodo event
    socket.on("addTodo", (todo) => {
        //👇🏻 adds the todo to a list of todos
        todoList.unshift({ _id: generateID(), title: todo, comments: [] });
        //👇🏻 sends a new event containing the todos
        socket.emit("todos", todoList);
    });

    socket.on("disconnect", () => {
        socket.disconnect();
        console.log("🔥: A user disconnected");
    });
});


Enter fullscreen mode Exit fullscreen mode

How to display the to-dos

Import socket from the socket.js file into the Home.js file.



import socket from "../utils/socket";


Enter fullscreen mode Exit fullscreen mode

Create an event listener to the to-dos created on the server and render them on the client.



const [data, setData] = useState([]);

useLayoutEffect(() => {
    socket.on("todos", (data) => setData(data));
}, [socket]);


Enter fullscreen mode Exit fullscreen mode

The todos event is triggered only when you create a new to-do. Next, create a route on the server that returns the array of to-dos so you can fetch them via API request within the app.

Update the index.js file on the server to send the to-do list via an API route as below.



app.get("/todos", (req, res) => {
    res.json(todoList);
});


Enter fullscreen mode Exit fullscreen mode

Add the code snippet below to the Home.js file:



//👇🏻 fetch the to-do list on page load
useLayoutEffect(() => {
    function fetchTodos() {
        fetch("http://localhost:4000/todos")
            .then((res) => res.json())
            .then((data) => setData(data))
            .catch((err) => console.error(err));
    }
    fetchTodos();
}, []);



Enter fullscreen mode Exit fullscreen mode

How to delete the to-dos

From the image below, there is a delete icon beside each to-do. When you press the button, the selected todo should be deleted on both the server and within the app.

Image description

Navigate to the Todo.js file and import Socket.io.



import socket from "../utils/socket";


Enter fullscreen mode Exit fullscreen mode

Create a function - deleteTodo that accepts the to-do id when you press the delete icon and sends it to the server.



import { View, Text, StyleSheet } from "react-native";
import { React } from "react";
import { AntDesign } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import socket from "../utils/socket";

const Todo = ({ item }) => {
    const navigation = useNavigation();

    //👇🏻 deleteTodo function
    const deleteTodo = (id) => socket.emit("deleteTodo", id);

    return (
        <View style={styles.todoContainer}>
            <View>
                <Text style={styles.todoTitle}>{item.title}</Text>
                <Text
                    style={styles.subTitle}
                    onPress={() =>
                        navigation.navigate("Comments", {
                            title: item.title,
                            id: item._id,
                        })
                    }
                >
                    View comments
                </Text>
            </View>
            <View>
                <AntDesign
                    name='delete'
                    size={24}
                    color='red'
                    onPress={() => deleteTodo(item._id)}
                />
            </View>
        </View>
    );
};

export default Todo;


Enter fullscreen mode Exit fullscreen mode

Delete the to-do via its ID.



socket.on("deleteTodo", (id) => {
    let result = todoList.filter((todo) => todo._id !== id);
    todoList = result;
    //👇🏻 sends the new todo list to the app
    socket.emit("todos", todoList);
});


Enter fullscreen mode Exit fullscreen mode

Adding and displaying comments

When you click the View comments text, it navigates to the Comments page - where you can view all the comments related to the to-do.

Image description



<Text
    style={styles.subTitle}
    onPress={() =>
        navigation.navigate("Comments", {
            title: item.title,
            id: item._id,
        })
    }
>
    View comments
</Text>


Enter fullscreen mode Exit fullscreen mode

The navigation function accepts the title and id of the selected to-do as parameters; because we want the to-do title at the top of the route and also fetch its comments from the server via its ID.

To achieve this, update the useLayoutEffect hook within the Comments.js file to change the route's title and send the ID to the server.



useLayoutEffect(() => {
    //👇🏻 update the screen's title
    navigation.setOptions({
        title: route.params.title,
    });
    //👇🏻 sends the todo's id to the server
    socket.emit("retrieveComments", route.params.id);

    getUsername();
}, []);


Enter fullscreen mode Exit fullscreen mode

Listen to the retrieveComments event and return the to-do's comments.



socket.on("retrieveComments", (id) => {
    let result = todoList.filter((todo) => todo._id === id);
    socket.emit("displayComments", result[0].comments);
});


Enter fullscreen mode Exit fullscreen mode

Add another useLayoutEffect hook within the Comments.js file that updates the comments when it is retrieved from the server.



useLayoutEffect(() => {
    socket.on("displayComments", (data) => setCommentsList(data));
}, [socket]);


Enter fullscreen mode Exit fullscreen mode

To create new comments, update the addComment function by sending the comment details to the server.



const addComment = () =>{
    socket.emit("addComment", { comment, todo_id: route.params.id, user });
}


Enter fullscreen mode Exit fullscreen mode

Create the event listener on the server and add the comment to the list of comments.



socket.on("addComment", (data) => {
    //👇🏻 Filters the todo list
    let result = todoList.filter((todo) => todo._id === data.todo_id);
    //👇🏻 Adds the comment to the list of comments
    result[0].comments.unshift({
        id: generateID(),
        title: data.comment,
        user: data.user,
    });
    //👇🏻 Triggers this event to update the comments on the UI
    socket.emit("displayComments", result[0].comments);
});


Enter fullscreen mode Exit fullscreen mode

Congratulations!🥂 You’ve completed the project for this tutorial.

Conclusion

So far, you've learnt how to set up Socket.io in a React Native and Node.js application, save data with Async Storage, and communicate between a server and the Expo app via Socket.io.

This project is a demo of what you can build using React Native and Socket.io. Feel free to improve the project by using an authentication library and a database that supports real-time storage.

The source code for this application is available here: https://github.com/novuhq/blog/tree/main/build-todolist-with-reactnative

Thank you for reading!

Help me out!

If you feel like this article helped you understand WebSockets better! I would be super happy if you could give us a star! And let me also know in the comments ❤️
https://github.com/novuhq/novu
Image description

Thank you for reading!

Top comments (8)

Collapse
 
jterranova profile image
j-terranova

I am new to sockets and am having problems getting the code to work. I did see an inconsistency in the index file of the server and changed http.listen to app.listen. Also, was not sure if the code that assigns the socketIO is correct. It refers to port 3000 and the rest of the code refers to port 4000.
I added some console logs to try to troubleshoot. when I start the app I get the login in screen. After I press sign in I get the error:

ERROR [TypeError: Network request failed]
This error occurs after my console log
LOG Home - useLayoutEffect[socket] - todos data = []
useLayoutEffect(() => {
console.log("Home - useLayoutEffect[socket] - todos data = ", data)
if (data.length > 0)
{
socket.on("todos", (data) => setData(data));
}

}, [socket]);
Enter fullscreen mode Exit fullscreen mode

It does not appear that my requests are getting to the server but I do not know why. Can you provide some help in this regard?

Collapse
 
jterranova profile image
j-terranova

For Android I needed to replace localhost on the client with 10.0.2.2. Now it works great! Thanks

Collapse
 
restdbjones profile image
Jbee - codehooks.io

Great post! Real time apps gives amazing UX.
We've have created an ugly ToDo app variant in this blog post to show how to secure your API with Auth0.com integration.

Collapse
 
bobbyiliev profile image
Bobby Iliev

Very cool!

Collapse
 
everbliss7 profile image
Blessing Tayedzerwa

This is amazing bro🙌👏

Collapse
 
nevodavid profile image
Nevo David

Thank you 🤗
how are you today?

Collapse
 
sumitsaurabh927 profile image
Sumit Saurabh

Such an amazing article this is!

Loved how detailed it was and enjoyed reading it thoroughly.

Collapse
 
markstanley profile image
Mark Stanley (he/him)

Nice tutorial, many thanks for putting this together 🙏