Based on Learn React Course on Scrimba1
by Bob Ziroll
Trying to grasp TypeScript in React... strugling at the moment!
This is an excersice to practice what I learned in a basic course on TypeScript also in scrimba now applyed usccesfully in small React project found in an excellent course I found in scrimba too, actually after finished TypeScript course I read that Bob has also a course on React. Though I already knew React I've been working on JS only.
So the project started reading and writing in the editor and managing the data in localStorage
, then move data to firebase.
Starting React Project with only JS
//@see ./App.tsx
export default function App() {
const [notes, setNotes] = React.useState([])
const [currentNoteId, setCurrentNoteId] = React.useState(
(notes[0] && notes[0].id) || ""
)
function createNewNote() {
const newNote = {
id: nanoid(),
body: "# Type your markdown note's title here"
}
setNotes(prevNotes => [newNote, ...prevNotes])
setCurrentNoteId(newNote.id)
}
function updateNote(text) {
setNotes(oldNotes => oldNotes.map(oldNote => {
return oldNote.id === currentNoteId
? { ...oldNote, body: text }
: oldNote
}))
}
function findCurrentNote() {
return notes.find(note => {
return note.id === currentNoteId
}) || notes[0]
}
return (
<main>
{
notes.length > 0
?
<Split
sizes={[30, 70]}
direction="horizontal"
className="split"
>
<Sidebar
notes={notes}
currentNote={findCurrentNote()}
setCurrentNoteId={setCurrentNoteId}
newNote={createNewNote}
/>
{
currentNoteId &&
notes.length > 0 &&
<Editor
currentNote={findCurrentNote()}
updateNote={updateNote}
/>
}
</Split>
:
<div className="no-notes">
<h1>You have no notes</h1>
<button
className="first-note"
onClick={createNewNote}
>
Create one now
</button>
</div>
}
</main>
)
}
//@see ./components/Editor.tsx
export default function Editor({ currentNote, updateNote }) {
return (
<section className="pane editor">
<MDEditor
minHeight={80}
value={currentNote.body}
onChange={updateNote}
/>
</section>
);
}
//@see ./components/Sidebar.tsx
export default function Sidebar(props) {
const noteElements = props.notes.map((note, index) => (
<div key={note.id}>
<div
className={`title ${
note.id === props.currentNote.id ? "selected-note" : ""
}`}
onClick={() => props.setCurrentNoteId(note.id)}
>
<h4 className="text-snippet">Note {index + 1}</h4>
</div>
</div>
))
return (
<section className="pane sidebar">
<div className="sidebar--header">
<h3>Notes</h3>
<button className="new-note" onClick={props.newNote}>+</button>
</div>
{noteElements}
</section>
)
}
First attempt to migrate the React App to TS
I'm going to skip the first code for brevity porpus.
Me - Since I'm learning I'm going to explain what I did first and get a similar result on what you did but would like your opinion. I started defining my types from the components to the App. You did it from the App to the components, I must admit that your methodology it's clearer because it also the logic on going down when managing states variables, what do you think? However we got the same type error on the onChange property of the MDEditor component in
Editor.tsx
as follow:
Type '(text: string) => void' is not assignable to type '(value?: string | undefined, event?: ChangeEvent<HTMLTextAreaElement> | undefined, state?: ContextStore | undefined) => void'.
Types of parameters 'text' and 'value' are incompatible.
Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.ts(2322)
Types.d.ts(24, 5): The expected type comes from property 'onChange' which is declared here on type 'IntrinsicAttributes & MDEditorProps & RefAttributes<RefMDEditor>'
Trying to fix this is when I got a mess. How could we fix it?
AI - Let's break down the types for each component in your app and define them one at a time. TypeScript can be tricky initially, but defining the structure for each prop and state value should make it clearer.
Since you’re working with notes that have an id and body, let's start by creating a Note type
to represent each note’s structure:
// @see ./modals/types.ts -> has moved to index.ts
export interface Note {
id: string;
body: string;
}
Let's update the App component. We’ll need to set types for:
Notes (array of Note type)
currentNoteId (string)
createNewNote and updateNote functions.
Update your imports and state declarations with these types:
export default function App() {
const [notes, setNotes] = React.useState<Note[]>([]);
const [currentNoteId, setCurrentNoteId] = React.useState<string>(
(notes[0] && notes[0].id) || ""
);
function createNewNote() {
const newNote: Note = {
id: nanoid(),
body: "# Type your markdown note's title here"
};
setNotes(prevNotes => [newNote, ...prevNotes]);
setCurrentNoteId(newNote.id);
}
function updateNote(text: string) {
setNotes(oldNotes => oldNotes.map(oldNote => {
return oldNote.id === currentNoteId
? { ...oldNote, body: text }
: oldNote;
}));
}function createNewNote() {
const newNote: Note = {
id: nanoid(),
body: "# Type your markdown note's title here"
};
setNotes(prevNotes => [newNote, ...prevNotes]);
setCurrentNoteId(newNote.id);
}
function updateNote(text: string) {
setNotes(oldNotes => oldNotes.map(oldNote => {
return oldNote.id === currentNoteId
? { ...oldNote, body: text }
: oldNote;
}));
}
// more code goes here
In Editor, currentNote is of type Note, and updateNote is a function that accepts a string:
//@see ./components/Editor.tsx
import { Note } from './types';
interface EditorProps {
currentNote: Note;
updateNote: (text: string) => void;
}
export default function Editor({ currentNote, updateNote }: EditorProps) {
In Sidebar, we’ll define the props types for notes, currentNote, setCurrentNoteId, and newNote.
interface SidebarProps {
notes: Note[];
currentNote: Note;
setCurrentNoteId: (id: string) => void;
newNote: () => void;
}
export default function Sidebar({ notes, currentNote, setCurrentNoteId, newNote }: SidebarProps) {
const noteElements = notes.map((note, index) => ( // more code here -->
After these updates, the app should align well with TypeScript, and each component’s props should be type-safe. Let me know if you need help with additional tweaks!
Me Since I'm learning I'm going to explain what I did first and get a similar result on what you did but would like your opinion. I started defining my types from the components to the App, I must admit that your methodology it's clearer because it also the logic on going down when managing states variables what do you think. However we got the same type error on the onChange property of the MDEditor component in the Editor.tsx as follow:
Type '(text: string) => void' is not assignable to type '(value?: string | undefined, event?: ChangeEvent<HTMLTextAreaElement> | undefined, state?: ContextStore | undefined) => void'.
Types of parameters 'text' and 'value' are incompatible.
Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.ts(2322)
Types.d.ts(24, 5): The expected type comes from property 'onChange' which is declared here on type 'IntrinsicAttributes & MDEditorProps & RefAttributes<RefMDEditor>'
AI It sounds like you’re close! The issue with the onChange prop is due to MDEditor expecting a more complex function signature. Here, it’s expecting a function that takes value (potentially undefined) along with optional parameters like event and state.
We can adjust the onChange
function to handle the undefined
case for value
explicitly.
Instead of using updateNote
directly, wrap it in a function that aligns with the expected MDEditor signature.
Here’s how to modify Editor.tsx:
// Editor.tsx
import React from 'react';
import { Note } from './types';
interface EditorProps {
currentNote: Note;
updateNote: (text: string) => void;
}
export default function Editor({ currentNote, updateNote }: EditorProps) {
function handleEditorChange(value?: string) {
// Ensure we only pass a defined string to updateNote
if (value !== undefined) {
updateNote(value);
}
}
return (
<section className="pane editor">
<MDEditor
minHeight={80}
value={currentNote.body}
onChange={handleEditorChange}
/>
</section>
);
}
handleEditorChange
now accepts value?: string, which matches the type expected by MDEditor
.
It checks if value is defined, and if so, calls updateNote.
This keeps updateNote as (text: string) => void
, maintaining the simplicity in App.tsx.
This approach keeps updateNote focused on handling only defined strings and avoids adding complexity to the main app’s logic.
Me This clears it up! So we didn't need to specify the event?: React.ChangeEvent<HTMLTextAreaElement>
property in the MDEditorProps.onChange
since its optional?
xactly! Since event is optional in MDEditorProps.onChange, we can skip it if we don’t need it in our handler.
By only using the value parameter, TypeScript won’t expect you to handle the event or other optional arguments (event and state) unless you need them. This is helpful because it keeps your handleEditorChange function lean and focused on updating the note’s text, without extra complexity.
In cases where you do need to work with event or state, you can add them to the function signature as needed:
function handleEditorChange(value?: string, event?: React.ChangeEvent<HTMLTextAreaElement>) {
if (value !== undefined) {
updateNote(value);
}
// You could use `event` here if needed.
}
So yes, leaving out optional parameters in TypeScript is perfectly fine unless they're needed!
Me It works! Now I can continue developing in this app using TS, sure I'm going to come up with more doubts
localStorage fail validation
I'm going to skip the first code for brevity porpus.
Me I added this snnipet to be able to localStorage the text notes in the App like:
//App.tsx
const notesStorage: string = localStorage.getItem('notes');
const [notes, setNotes] = React.useState<Note[]>(
JSON.parse(notesStorage) || []
);
const [currentNoteId, setCurrentNoteId] = React.useState(
(notes[0] && notes[0].id) || ""
);
useEffect(() => {
localStorage.setItem('notes', JSON.stringify(notes))
}, [notes])
But the noteStorage
is complaining
Type 'string | null' is not assignable to type 'string'.
Type 'null' is not assignable to type 'string'.ts(2322)
So if I declare const notesStorage: string | null = localStorage.getItem('notes');
then in notesStorage
in the expresion:
const [notes, setNotes] = React.useState<Note[]>(
JSON.parse(notesStorage) || []
);
I got the error type
Argument of type 'string | null' is not assignable to parameter of type 'string'.
Type 'null' is not assignable to type 'string'.ts(2345)
So then how to set the type correctly?
To handle localStorage.getItem('notes'), which returns string | null in TypeScript, we need to check for null before attempting to parse it. Here’s a revised approach to safely parse notesStorage only if it’s not null.
// App.tsx
import React, { useEffect } from 'react';
const notesStorage = localStorage.getItem('notes');
const initialNotes: Note[] = notesStorage ? JSON.parse(notesStorage) : [];
const [notes, setNotes] = React.useState<Note[]>(initialNotes);
const [currentNoteId, setCurrentNoteId] = React.useState<string>(
(initialNotes[0] && initialNotes[0].id) || ""
);
useEffect(() => {
localStorage.setItem('notes', JSON.stringify(notes));
}, [notes]);
Initialize Safely: By using a conditional notesStorage ? JSON.parse(notesStorage) : [], we ensure JSON.parse is only called when notesStorage is a non-null string. Type-Safe Initial State: Assign initialNotes as the default value for notes when initializing state. This way, notes starts with an empty array if there’s nothing in localStorage. This approach avoids null issues while keeping notes type-safe as Note[].
Event Definition Type
interface SidebarProps {
notes: Note[];
currentNote: Note;
setCurrentNoteId: (id: string) => void;
newNote: () => void;
deleteNote: (id: string, event: Event | undefined) => void;
}
And adding this function to delete notes
//App.tsx
function deleteNote(noteId, event) {
event.stopPropagation();
setNotes(prevNotes => prevNotes.filter(note => note.id !== noteId));
}
//more code ...
<Sidebar
notes={notes}
currentNote={findCurrentNote()}
setCurrentNoteId={setCurrentNoteId}
newNote={createNewNote}
deleteNote={deleteNote}
/>
// Sidebar.tsx I added a delete button
<div key={note.id}>
<div
className={`title ${
note.id === currentNote.id ? "selected-note" : ""
}`}
onClick={() => setCurrentNoteId(note.id)}>
<h4 className="text-snippet">{note.body.split("\n")[0]}</h4>
<button
className="delete-btn"
onClick={() => deleteNote(note.id, event)}>
<i className="gg-trash trash-icon"></i>
</button>
</div>
</div>
Type Error:
The issue here is that TypeScript doesn’t know the type of event in the deleteNote function. Since the event comes from a button’s onClick, we can explicitly type it as React.MouseEvent<HTMLButtonElement>
.
Here's how to update your code:
Type the noteId as string and event as React.MouseEvent<HTMLButtonElement>
. This will clarify that deleteNote expects a click event from a button:
// App.tsx
import React from 'react';
function deleteNote(noteId: string, event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
setNotes(prevNotes => prevNotes.filter(note => note.id !== noteId));
}
In Sidebar, we’ll also ensure deleteNote is called with the correct event. Instead of () => deleteNote(note.id, event)
, pass the event
directly by modifying onClick to capture the event parameter:
<button
className="delete-btn"
onClick={(event) => deleteNote(note.id, event)}>
<i className="gg-trash trash-icon"></i>
</button>
The component now directly passes the event to deleteNote:
interface SidebarProps {
notes: Note[];
currentNote: Note;
setCurrentNoteId: (id: string) => void;
newNote: () => void;
deleteNote: (noteId: string, event: React.MouseEvent<HTMLButtonElement>) => void;
}
export default function Sidebar({ notes, currentNote, setCurrentNoteId, newNote, deleteNote }: SidebarProps) {
return (
<section className="pane sidebar">
<div className="sidebar--header">
<h3>Notes</h3>
<button className="new-note" onClick={newNote}>+</button>
</div>
{notes.map((note) => (
<div key={note.id}>
<div
className={`title ${note.id === currentNote.id ? "selected-note" : ""}`}
onClick={() => setCurrentNoteId(note.id)}>
<h4 className="text-snippet">{note.body.split("\n")[0]}</h4>
<button
className="delete-btn"
onClick={(event) => deleteNote(note.id, event)}>
<i className="gg-trash trash-icon"></i>
</button>
</div>
</div>
))}
</section>
);
}
This ensures TypeScript understands the types, making your app both type-safe and functionally correct.
Me
How could I skip this part to add the event parameter at calling the function on the click event!! onClick={(event) => deleteNote(note.id, event)}>
. And even with that little bug the delete button was working but now as you stated makes the app both type-safe and functionally correct. I see that with TypeScript I force myself to know what I'm doing when programming!
AI Absolutely right—TypeScript can feel like a strict teacher, but it really helps to catch those little details that can easily slip by otherwise. By enforcing types, it encourages you to think through exactly how data flows through your app, which ultimately makes everything more predictable and robust. It’s helping you deepen your understanding!
Type Error on switching to firebase
firebase issues 2
Finally I finished my little App and I switch localStorage to firbade to store the text, everything is working except for two things, one is a type error and the other it's an issue of performance when I add a feature. But first I would like to takle the type error... let's see:
//I skiped all the import for brevety
//./models/index.ts
interface Note {
id: string;
body?: string;
createdAt: number;
updatedAt: number;
}
interface EditorProps {
tempNoteText: string;
setTempNoteText: React.Dispatch<React.SetStateAction<string>>;
}
interface SidebarProps {
notes: Note[];
currentNote: Note;
setCurrentNoteId: (id: string) => void;
addNote: () => void;
deleteNote: (id: string) => void;
}
export type { Note, EditorProps, SidebarProps };
// App.tsx
// Some imports here
export default function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [currentNoteId, setCurrentNoteId] = useState("");
const [tempNoteText, setTempNoteText] = useState("");
const currentNote =
notes.find(note => note.id === currentNoteId) || notes[0];
const sortedNotes = notes.sort((a, b) => b.updatedAt - a.updatedAt);
useEffect(() => {
if (currentNote && currentNote.body !== undefined)
setTempNoteText(currentNote.body);
}, [currentNote]);
useEffect(
() =>
onSnapshot(notesCollection, snapshot =>
setNotes(
snapshot.docs.map(doc => ({ // here is the type error(1)
id: doc.id,
...doc.data()
}))
)
),
[]
);
useEffect(() => {
if (!currentNoteId) {
setCurrentNoteId(notes[0]?.id);
}
}, [currentNoteId, notes]);
const addNote = async () => {
const newNoteRef = await addDoc(notesCollection, {
body: "**Type your markdown note's title here**",
createdAt: Date.now(),
updatedAt: Date.now()
});
setCurrentNoteId(newNoteRef.id);
};
const deleteNote = async (noteId: string) => {
const docRef = doc(firestore, "notes", noteId);
try {
await deleteDoc(docRef);
} catch (error) {
console.log(`I got an error ${error}`);
}
};
useEffect(() => {
const timeoutId = setTimeout(() => {
const updateNote = async (text: string) => {
const docRef = doc(firestore, "notes", currentNoteId);
await setDoc(
docRef,
{ body: text, updatedAt: Date.now() },
{ merge: true }
);
};
updateNote(tempNoteText);
}, 500);
return () => clearTimeout(timeoutId);
}, [tempNoteText, currentNoteId]);
return (
<main>
{notes.length > 0 ? (
<Split
style={{
height: "100%",
border: "1px solid #d5d5d5",
borderRadius: 3
}}>
<Sidebar
notes={sortedNotes}
currentNote={currentNote}
setCurrentNoteId={setCurrentNoteId}
addNote={addNote}
deleteNote={deleteNote}
/>
<Editor
tempNoteText={tempNoteText}
setTempNoteText={setTempNoteText}
/>
</Split>
) : (
<div className="no-notes">
<h1>You have no notes</h1>
<button className="first-note" onClick={() => addNote()}>
Create one now
</button>
</div>
)}
</main>
);
}
//Editor.tsx
export default function Editor({ tempNoteText, setTempNoteText }: EditorProps) {
const handleChange = (text?: string) => {
if (text !== undefined) setTempNoteText(text);
};
return (
<section className="pane editor">
<MDEditor
minHeight={200}
value={tempNoteText}
onChange={handleChange}
previewOptions={{
rehypePlugins: [[rehypeSanitize]]
}}
/>
</section>
);
}
// Sidebar.tsx
export default function Sidebar({
notes,
currentNote,
setCurrentNoteId,
addNote,
deleteNote
}: SidebarProps) {
const noteElements = notes.map(note => {
return (
<div key={note.id}>
<div
className={`title ${
note.id === currentNote.id ? "selected-note" : ""
}`}
onClick={() => setCurrentNoteId(note.id!)}>
<h4 className="text-snippet">{note.body?.split("\n")[0]}</h4>
<button
className="delete-btn"
onClick={() => deleteNote(note.id!)}>
<i className="gg-trash trash-icon"></i>
</button>
</div>
</div>
);
});
return (
<section className="pane sidebar">
<div className="sidebar--header">
<h3>Notes</h3>
<button className="new-note" onClick={addNote}>
+
</button>
</div>
{noteElements}
</section>
);
}
//(1)Error:
Argument of type '{ id: string; }[]' is not assignable to parameter of type 'SetStateAction<Note[]>'.
How to fix that? I made sereral tries and fail.
When mapping over the snapshot.docs
, cast doc.data()
to Note. This will align the data structure with Note[] and satisfy TypeScript's type checking.
Here’s how to apply the fix:
// Inside the App component
useEffect(() => {
const unsubscribe = onSnapshot(notesCollection, snapshot =>
setNotes(
snapshot.docs.map(doc => ({
id: doc.id,
...(doc.data() as Note), // Cast doc.data() as Note
}))
)
);
return () => unsubscribe();
}, []);
doc.data() as Note
: This casting tells TypeScript to treat doc.data()
as an object of type Note, resolving the type mismatch.
This approach makes setNotes understand that it’s dealing with an array of Note objects, satisfying the setNotes function’s expected input of SetStateAction<Note[]>.
Additional Tips
Check Optional Fields: If any properties on Note are optional (e.g., body), make sure Note allows for undefined values as needed.
Error Handling: If doc.data() may not always return the full Note structure (e.g., in case of a data corruption), consider adding some fallback values or error handling.
This should resolve the type error you’re encountering with Firebase data! Let me know if you have more questions or if you'd like help on the performance issue next.
Me This solution fix it but shift the error to the inside line:
// Inside the App component
snapshot.docs.map(doc => ({
id: doc.id, // Type error [^te].
...(doc.data() as Note)
}))
//[^te]: Type error:
'id' is specified more than once, so this usage will be overwritten.ts(2783)
App.tsx(27, 25): This spread always overwrites this property.
I just changed that sugested line as:
snapshot.docs.map(doc => ({
...(doc.data() as Note),
id: doc.id
}))
That clear up all the type erros and appatentely it's working fine.
AI Great catch! Reordering the id property after spreading doc.data() avoids the duplicate assignment issue and ensures that id always holds the value of doc.id. This approach preserves the intended structure without any conflict.
Now your Firebase data should load correctly, and TypeScript can verify that everything is in place. Nicely done handling that refinement—glad to hear it’s working smoothly now!
Performance issue
Now lets try to tackle the performance issue. If I click in any note of the sidebar the note is updated after 500ms automatically. But I want to update it till I do some text changes on the editor. So to achieve that I put a condition like this:
useEffect(() => {
const timeoutId = setTimeout(() => {
if (tempNoteText !== currentNote.body) { //This is the condition added
const updateNote = async (text: string) => {
const docRef = doc(firestore, "notes", currentNoteId);
await setDoc(
docRef,
{ body: text, updatedAt: Date.now() },
{ merge: true }
);
};
updateNote(tempNoteText);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [tempNoteText, currentNoteId, currentNote.body]);
So that condition should prevent to update till the body text in the editor change. However the App crashed with this error:
App.tsx:71 Uncaught TypeError: Cannot read properties of undefined (reading 'body')
at App (App.tsx:71:50)
AI The crash happens because currentNote can be undefined, particularly if there are no notes or if currentNoteId doesn’t match any note ID in the notes array. When you access currentNote.body, TypeScript or JavaScript tries to read body from an undefined currentNote, leading to the error.
To fix this, you can add a check to ensure currentNote is defined before accessing its body. Here’s how to implement it safely:
Add a check to confirm that currentNote
exists before attempting to read currentNote.body
.
You can handle cases where currentNote
is undefined
by either setting a fallback value or skipping the update entirely.
Here’s the revised useEffect
:
useEffect(() => {
const timeoutId = setTimeout(() => {
// Ensure currentNote is defined and has a body
if (currentNote && tempNoteText !== currentNote.body) {
const updateNote = async (text: string) => {
const docRef = doc(firestore, "notes", currentNoteId);
await setDoc(
docRef,
{ body: text, updatedAt: Date.now() },
{ merge: true }
);
};
updateNote(tempNoteText);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [tempNoteText, currentNoteId, currentNote?.body]); // Add optional chaining
Check for currentNote: if (currentNote && tempNoteText !== currentNote.body)
ensures that currentNote
is defined before comparing tempNoteText
to currentNote.body
.
Optional chaining in dependencies: By using currentNote?.body
, you prevent the dependency array from crashing if currentNote
is undefined
.
This should prevent the crash while also maintaining the condition you added for updating only when tempNoteText changes relative to currentNote.body
.
Refactoring using Context API
Refactoring by using the COntext API3 simplifies the definition of types in on container type
interface Note {
id: string;
body: string;
createdAt: Date;
updatedAt: Date;
}
type TypeNoteContext = {
notes: Note[];
currentNote: Note;
setCurrentNoteId: (id: string) => void;
addNote: () => void;
deleteNote: (id: string) => void;
darkMode: boolean;
toggleDarkMode: () => void
tempNoteText: string;
setTempNoteText: React.Dispatch<React.SetStateAction<string>>;
};
Me I learned a lot in this exercise of migrating this ReactJS App to TypeScript. Your an outstanding instructor! I thing I finish with this little App. But surly will start some more and getting more confident as more I use TS.
AI It’s great to see you mastering TypeScript with your React projects. You’ve tackled a wide range of important concepts and really elevated the app with both type safety and performance considerations. Keep going with the TypeScript journey—each project will deepen your confidence even more. Feel free to reach out anytime for more insights or questions. Happy coding!
Footnotes
-
Thou the course is outdated based on React 17 with js, it's a very good excercise! ↩
-
For security reasons I did not include my firebase config file. ↩
-
useContext()
always looks for the closest provider above the component that calls it. It searches upwards and does not consider providers in the component from which you’re callinguseContext()
. That's why it cannot be any state in the provider. ↩