Compare commits

...

14 Commits

Author SHA1 Message Date
f60847f9aa For some reason now it works properly.
NOTE: this solution is unstable and can change in the future.
2025-04-02 17:04:55 +02:00
57c7440e65 Invalidation when creating new is correct, but the invalidation when editing a contact doesn't work properly. 2025-04-02 16:51:33 +02:00
94346dd242 New Data handler with lowdb 2025-04-02 12:06:12 +02:00
a54d7b57ee Some notes on Server Components in edit/page.tsx 2025-04-01 22:30:10 +02:00
b73c9311f5 Complete "Updating Contacts with FormData" 2025-04-01 17:58:39 +02:00
434b7c8dc8 Remove caching. That caching is breaking the program in case of change.
Note: "use server" is not good because force the fetch to be action/static so the client doesn't call them again explicitly during render (I think?)
2025-04-01 17:58:11 +02:00
800c231580 Loading is not easily possible here. I will see later. 2025-04-01 17:21:15 +02:00
aa81948f29 Edit Contact but without loading? 2025-04-01 17:14:43 +02:00
a4536d20b4 Complete "Creating Contacts". 2025-04-01 16:40:35 +02:00
75337be90c Complete "URL Params in Loaders" and "Throwing Responses". 2025-04-01 14:10:20 +02:00
298d25a579 Complete "Layout Routes". 2025-04-01 12:17:08 +02:00
276bea81b0 Remove unused "Hydrate Fallback".
Note: As already said, this loading appear even when the app/layout.tsx is loading. This is not the behaviour I want. In fact the behaviour I wanted is impossible to be needed with Next.js and his "SSR first" approach.
2025-04-01 11:42:49 +02:00
595520296c Complete "Hydrate Fallback".
Note: I checked in React DevTool that the Suspend and the fallback is present, but the skeleton of the page is already rendered into the server so that Suspend is impossible to trigger!
You can see it as it is during long load, o I will probably remove it in the next commit.
2025-04-01 11:41:33 +02:00
4330157491 Complete "Loading Data" 2025-04-01 11:32:54 +02:00
15 changed files with 641 additions and 302 deletions

View File

@@ -9,10 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"lowdb": "^7.0.1",
"match-sorter": "^8.0.0",
"next": "15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
"sort-by": "^1.2.0",
"tiny-invariant": "^1.3.3"
},

25
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
lowdb:
specifier: ^7.0.1
version: 7.0.1
match-sorter:
specifier: ^8.0.0
version: 8.0.0
@@ -20,6 +23,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.1.0(react@19.1.0)
server-only:
specifier: ^0.0.1
version: 0.0.1
sort-by:
specifier: ^1.2.0
version: 1.2.0
@@ -1118,6 +1124,10 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lowdb@7.0.1:
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==}
engines: {node: '>=18'}
match-sorter@8.0.0:
resolution: {integrity: sha512-bGJ6Zb+OhzXe+ptP5d80OLVx7AkqfRbtGEh30vNSfjNwllu+hHI+tcbMIT/fbkx/FKN1PmKuDb65+Oofg+XUxw==}
@@ -1348,6 +1358,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -1401,6 +1414,10 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
steno@4.0.2:
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
engines: {node: '>=18'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
@@ -2758,6 +2775,10 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lowdb@7.0.1:
dependencies:
steno: 4.0.2
match-sorter@8.0.0:
dependencies:
'@babel/runtime': 7.27.0
@@ -2996,6 +3017,8 @@ snapshots:
semver@7.7.1: {}
server-only@0.0.1: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -3092,6 +3115,8 @@ snapshots:
stable-hash@0.0.5: {}
steno@4.0.2: {}
streamsearch@1.1.0: {}
string.prototype.includes@2.0.1:

View File

@@ -1,57 +1,60 @@
"use client";
import Form from "next/form";
import { ContactRecord } from "@/app/data";
import Form from "next/form";
import { use } from "react";
import { useRouter } from "next/navigation";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://placecats.com/200/200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
export default function Contact({
contact
}: {
contact: Promise<ContactRecord>
}) {
const c = use(contact);
const router = useRouter();
return (
<div id="contact">
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={`${contact.first} ${contact.last} avatar`}
key={contact.avatar}
src={contact.avatar}
alt={`${c.first} ${c.last} avatar`}
key={c.avatar}
src={c.avatar}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
{c.first || c.last ? (
<>
{contact.first} {contact.last}
{c.first} {c.last}
</>
) : (
<i>No Name</i>
)}
<Favorite contact={contact}/>
<Favorite contact={c}/>
</h1>
{contact.twitter ? (
{c.twitter ? (
<p>
<a
href={`https://twitter.com/${contact.twitter}`}
href={`https://twitter.com/${c.twitter}`}
>
{contact.twitter}
{c.twitter}
</a>
</p>
) : null}
{contact.notes ? <p>{contact.notes}</p> : null}
{c.notes ? <p>{c.notes}</p> : null}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<button
type="button"
onClick={() => router.push(`/contacts/${c.id}/edit`)}
>
Edit
</button>
<Form
action="destroy"

View File

@@ -0,0 +1,87 @@
import Form from "next/form";
import { fetchContact } from "@/app/(sidebar)/loaders";
import { notFound, redirect } from "next/navigation";
import { updateContact } from "@/app/data";
import { revalidatePath } from "next/cache";
async function editContact(contactId: string, formData: FormData) {
"use server";
const updates = Object.fromEntries(formData);
await updateContact(contactId, updates);
revalidatePath('/(sidebar)', 'layout'); // THE NESTED BEHAVIOR IS UNSTABLE AND CAN CHANGE IN FUTURE.
redirect(`/contacts/${contactId}`); // This makes the server component data be reloaded.
/*
* The server component architecture is a HTMX-like structure where the component rendered is STATE-LESS
* and that means that they don't automagically update.
* How do you say a server component to "re-render"? YOU REFRESH THE PAGE! THIS IS AS INTENDED!!!
* Do you want a component that re-render interactively from a "signal" client-side?
* That's a classical Client Component you dummy!! Go fetch clientside library and use that instead of Server Comps.
*/
}
export default async function EditContactPage({
params
}: {
params: Promise<{ contactId: string }>
}) {
const { contactId } = await params;
const contact = await fetchContact(contactId).then((c) => {
if (c === null) notFound();
return c;
});
const editThisContact = editContact.bind(null, contactId);
return (
<Form key={contact.id} id="contact-form" action={editThisContact}>
<p>
<span>Name</span>
<input
aria-label="First name"
defaultValue={contact.first}
name="first"
placeholder="First"
type="text"
/>
<input
aria-label="Last name"
defaultValue={contact.last}
name="last"
placeholder="Last"
type="text"
/>
</p>
<label>
<span>Twitter</span>
<input
defaultValue={contact.twitter}
name="twitter"
placeholder="@jack"
type="text"
/>
</label>
<label>
<span>Avatar URL</span>
<input
aria-label="Avatar URL"
defaultValue={contact.avatar}
name="avatar"
placeholder="https://example.com/avatar.jpg"
type="text"
/>
</label>
<label>
<span>Notes</span>
<textarea
defaultValue={contact.notes}
name="notes"
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}

View File

@@ -0,0 +1,5 @@
export default function ContactLoading() {
return (
<div>Loading Contact...</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function NotFound() {
return (
<main id="error-page">
<h1>404</h1>
<p>The requested contact could not be found.</p>
</main>
);
}

View File

@@ -0,0 +1,22 @@
import { notFound } from "next/navigation";
import Contact from "@/app/(sidebar)/contacts/[contactId]/Contact";
import { fetchContact } from "@/app/(sidebar)/loaders";
import React, { Suspense } from "react";
export default async function ContactPage({
params
}: {
params: Promise<{ contactId: string }>
}) {
const { contactId } = await params;
const contact = fetchContact(contactId).then((c) => {
if (c === null) notFound();
return c;
});
return (
<Suspense fallback={<div>Loading contact...</div>}>
<Contact contact={contact}/>
</Suspense>
);
}

View File

@@ -0,0 +1,56 @@
import React, { Suspense } from "react";
import Link from "next/link";
import Form from "next/form";
import ContactList from "./sidebar-contacts";
import { createEmptyContact } from "@/app/data";
import { revalidatePath } from "next/cache";
import { fetchContacts } from "@/app/(sidebar)/loaders";
async function newContact() {
"use server";
await createEmptyContact();
revalidatePath('/(sidebar)', 'layout');
}
export default function SidebarRootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const contacts = fetchContacts();
return (
<>
<div id="sidebar">
<h1>
<Link href="/about">React Router Contacts</Link>
</h1>
<div>
<Form id="search-form" role="search" action="." formMethod="get">
<input
aria-label="Search contacts"
id="q"
name="q"
placeholder="Search"
type="search"/>
<div
aria-hidden
hidden={true}
id="search-spinner"/>
</Form>
<Form action={newContact}>
<button type="submit">New</button>
</Form>
</div>
<nav>
<Suspense fallback={<div>Loading...</div>}>
<ContactList contacts={contacts}/>
</Suspense>
</nav>
</div>
<div id={'detail'}>
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { getContact, getContacts } from "@/app/data";
// This files is a non-needed interface file. I can directly call getContact/s into Server Components.
export const fetchContacts = async () => {
console.log("fetch contacts");
return await getContacts();
};
export const fetchContact = async (contactId: string) => {
console.log(`fetch contact ${contactId}`);
return await getContact(contactId);
};

View File

@@ -0,0 +1,15 @@
import React from "react";
export default function Home() {
return (
<p id="index-page">
This is a demo for React Router.
<br/>
Check out{" "}
<a href="https://reactrouter.com">
the docs at reactrouter.com
</a>
.
</p>
);
}

View File

@@ -0,0 +1,27 @@
import { ContactRecord } from "@/app/data";
import React, { use } from "react";
import Link from "next/link";
export default function ContactList({ contacts }: { contacts: Promise<ContactRecord[]> }) {
const allContacts = use(contacts);
return (
<ul>
{allContacts.map((c) => (
<li key={c.id}>
<Link href={`/contacts/${c.id}`}>
{c.first || c.last ? (
<>
{c.first} {c.last}
</>
) : (
<i>No Name</i>
)}
{c.favorite ? (
<span></span>
) : null}
</Link>
</li>
))}
</ul>
);
}

45
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,45 @@
import Link from "next/link";
export default function About() {
return (
<div id="about">
<Link href="/"> Go to demo</Link>
<h1>About React Router Contacts</h1>
<div>
<p>
This is a demo application showing off some of the
powerful features of React Router, including
dynamic routing, nested routes, loaders, actions,
and more.
</p>
<h2>Features</h2>
<p>
Explore the demo to see how React Router handles:
</p>
<ul>
<li>
Data loading and mutations with loaders and
actions
</li>
<li>
Nested routing with parent/child relationships
</li>
<li>URL-based routing with dynamic segments</li>
<li>Pending and optimistic UI</li>
</ul>
<h2>Learn More</h2>
<p>
Check out the official documentation at{" "}
<a href="https://reactrouter.com">
reactrouter.com
</a>{" "}
to learn more about building great web
applications with React Router.
</p>
</div>
</div>
);
}

View File

@@ -2,11 +2,16 @@
// 🛑 Nothing in here has anything to do with React Router, it's just a fake database
////////////////////////////////////////////////////////////////////////////////
// noinspection UnnecessaryLocalVariableJS,JSUnusedGlobalSymbols
import "server-only";
import { matchSorter } from "match-sorter";
// @ts-expect-error - no types, but it's a tiny function
import sortBy from "sort-by";
import invariant from "tiny-invariant";
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { join } from "path";
import { mkdir } from "fs/promises";
type ContactMutation = {
id?: string;
@@ -23,42 +28,97 @@ export type ContactRecord = ContactMutation & {
createdAt: string;
};
////////////////////////////////////////////////////////////////////////////////
// This is just a fake DB table. In a real app you'd be talking to a real db or
// fetching from an existing API.
const fakeContacts = {
records: {} as Record<string, ContactRecord>,
// Define the database schema
type Schema = {
records: Record<string, ContactRecord>;
};
// Set up lowdb
const initDb = async (): Promise<Low<Schema>> => {
// Ensure the db directory exists
const dbDir = join(process.cwd(), '.db');
await mkdir(dbDir, { recursive: true });
const file = join(dbDir, 'contacts.json');
const adapter = new JSONFile<Schema>(file);
const defaultData: Schema = { records: {} };
const db = new Low<Schema>(adapter, defaultData);
// Load existing data
await db.read();
// Initialize if needed
if (!db.data) {
db.data = defaultData;
}
return db;
};
// Create a singleton instance of the database
let dbPromise: Promise<Low<Schema>> | null = null;
const getDb = () => {
if (!dbPromise) {
dbPromise = initDb();
}
return dbPromise;
};
////////////////////////////////////////////////////////////////////////////////
// This is a DB wrapper using lowdb for persistence
const fakeContacts = {
async getAll(): Promise<ContactRecord[]> {
return Object.keys(fakeContacts.records)
.map((key) => fakeContacts.records[key])
const db = await getDb();
return Object.keys(db.data.records)
.map((key) => db.data.records[key])
.sort(sortBy("-createdAt", "last"));
},
async get(id: string): Promise<ContactRecord | null> {
return fakeContacts.records[id] || null;
const db = await getDb();
return db.data.records[id] || null;
},
async create(values: ContactMutation): Promise<ContactRecord> {
const db = await getDb();
const id = values.id || Math.random().toString(36).substring(2, 9);
const createdAt = new Date().toISOString();
const newContact = { id, createdAt, ...values };
fakeContacts.records[id] = newContact;
db.data.records[id] = newContact;
await db.write();
return newContact;
},
async set(id: string, values: ContactMutation): Promise<ContactRecord> {
const contact = await fakeContacts.get(id);
const db = await getDb();
const contact = await this.get(id);
invariant(contact, `No contact found for ${id}`);
const updatedContact = { ...contact, ...values };
fakeContacts.records[id] = updatedContact;
db.data.records[id] = updatedContact;
await db.write();
return updatedContact;
},
destroy(id: string): null {
delete fakeContacts.records[id];
async destroy(id: string): Promise<null> {
const db = await getDb();
delete db.data.records[id];
await db.write();
return null;
},
// New reset function to restore the initial data
async reset(): Promise<void> {
const db = await getDb();
db.data.records = {};
await db.write();
// Restore initial data
const initialContacts = getInitialContacts();
for (const contact of initialContacts) {
await this.create(contact);
}
}
};
////////////////////////////////////////////////////////////////////////////////
@@ -93,10 +153,18 @@ export async function updateContact(id: string, updates: ContactMutation) {
}
export async function deleteContact(id: string) {
fakeContacts.destroy(id);
await fakeContacts.destroy(id);
}
[
// New function to reset the database to initial state
export async function resetDatabase() {
console.log("RESET DATABASE");
await fakeContacts.reset();
}
// Helper function to get initial contacts data
function getInitialContacts() {
return [
{
avatar:
"https://sessionize.com/image/124e-400o400o2-wHVdAuNaxi8KJrgtN3ZKci.jpg",
@@ -309,12 +377,19 @@ export async function deleteContact(id: string) {
last: "Jensen",
twitter: "@jenseng",
},
].forEach((contact) => {
fakeContacts.create({
].map(contact => ({
...contact,
id: `${contact.first
.toLowerCase()
.split(" ")
.join("_")}-${contact.last.toLocaleLowerCase()}`,
});
});
.join("_")}-${contact.last.toLowerCase()}`,
}));
}
// Initialize with seed data on first load
(async () => {
const db = await getDb();
if (Object.keys(db.data.records).length === 0) {
await resetDatabase();
}
})();

View File

@@ -1,9 +1,7 @@
import type { Metadata } from "next";
import React from "react";
import type { Metadata } from "next";
import "./globals.css";
import Form from "next/form";
import Link from "next/link";
export const metadata: Metadata = {
title: "Next.js Address Book",
@@ -18,41 +16,7 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search" action="." formMethod="get">
<input
aria-label="Search contacts"
id="q"
name="q"
placeholder="Search"
type="search"
/>
<div
aria-hidden
hidden={true}
id="search-spinner"
/>
</Form>
<Form action="." formMethod="post">
<button type="submit">New</button>
</Form>
</div>
<nav>
<ul>
<li>
<Link href={`/contacts/1`}>Your Name</Link>
</li>
<li>
<Link href={`/contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
</div>
<div id={'detail'}>
{children}
</div>
</body>
</html>
);

View File

@@ -1,8 +0,0 @@
import Form from "next/form";
import React from "react";
export default function Home() {
return (
<div></div>
);
}