Skip to main content

The Attention Revolution: WeChat

· 2 min read
Wu Kaipeng
Frontend Developer

Attention overload has become a new kind of modern-day "sub-health."

On average, a person encounters 6,000 to 10,000 ads every day, whether they mean to or not.

That number is twice what it was 20 years ago.

You can see how much money is involved by looking at companies like Tencent, whose ad business has grown from around 200 million to now being worth hundreds of billions.

A Less-Known but Powerful React Hook: useSyncExternalStore

· 4 min read
吴楷鹏
前端开发工程师

In general, React state comes from inside the component itself, such as values created with useState.

But sometimes, the state comes from somewhere else. As shown in the classic example from the React docs, we may need to track whether the browser is online:

If the network is available, show ✅ Online; otherwise show ❌ Disconnected.

Online

Offline

Use Chrome DevTools > Network to simulate online/offline states.

Here, the online status comes from the external value navigator.onLine, so useSyncExternalStore is a perfect fit:

import { useSyncExternalStore } from 'react';

export default function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function getSnapshot() {
return navigator.onLine;
}

function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

As you can see, the basic setup for useSyncExternalStore includes a subscribe function and a getSnapshot function:

const isOnline = useSyncExternalStore(subscribe, getSnapshot);

If you look closely at the subscribe function, it runs logic inside the function, calls callback, and finally returns a cleanup function. This feels very similar to the usual effect hook useEffect:

function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

That callback is important: React calls it like a delivery person, and it asks getSnapshot for the latest value. If the value has changed, React immediately updates the component.

At this point, you might think that useEffect could also do the same thing:

import { useState, useEffect } from 'react';

export default function ChatIndicator() {
const [isOnline, setIsOnline] = useState(navigator.onLine);

useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);

return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

If useEffect can do the same thing, is useSyncExternalStore just unnecessary extra work?

This is where React tearing becomes important. Consider a simple component like this:

React Tearing Demo

If you look carefully, when the page first opens, these counters show different values such as 218, 219, and 220, and then they all eventually become 221.

In reality, all of these counters are reading from the same external data source. In theory, they should always stay in sync.

But React 18’s concurrent rendering model doesn’t always behave politely.

Because rendering happens concurrently, some components may update to the latest external value while others lag behind.

That lag creates the visual effect of components being “torn” apart.

This is exactly why useSyncExternalStore exists. It calls getSnapshot during rendering to check whether the value is still current. If not, React discards the current render and performs a synchronous, non-blocking rerender with the latest value:

内部分享:开发利器 React Query

· 9 min read

背景

@tanstack/react-query (下称 RQ)在 GitHub 上已经累积了 4w+ 的 ⭐,称得上 React 技术栈中必不可少的工具。

它并不像 Zustand 这些通用的状态管理库,而是针对请求这一场景,做了相关的深度优化和功能定制

RQ 在当前项目已经引入将近两个月,但是使用情况并不多,障碍可能来自于 RQ 的学习曲线和心智认知,所以开展本次 RQ 分享,让团队各位更多地了解 RQ 的能力,进而利用好 RQ,提高开发效率。

RQ 和 Axios 的关系

目前我们的项目中使用 Axios 作为请求器,而 RQ 并不是用来替代 Axios,而是用来增强 Axios 的。

看一下例子:

// 👉 这是用 Axios 去实现的获取站内信未读总数的接口方法
export function getUnreadNotificationCount(): Promise<number> {
return clientApi.get(URL.GET_UNREAD_NOTIFICATION_COUNT, {
fetchOptions: {
experimental_throw_all_errors: true,
experimental_no_toast_when_error: true,
},
});
}

// 👉 使用 RQ 通过 hook 封装的方式,去增强未读总信接口
import { useQuery } from '@tanstack/react-query';
export const useUnreadNotificationCount = () => {
return useQuery({
queryKey: ['notification', 'unread-count'],
// 👉 RQ 最终调用我们提供给接口方法
queryFn: getUnreadNotificationCount,
});
};

所以 RQ 其实是请求框架无关的工具,它可以和 Axios,GraphQL,或者原生 fetch() 方法等结合使用。

功能一:减少样板代码

不使用 RQ

假设我们不使用 RQ,那么正常调用一个接口就是:

import { useState, useEffect } from 'react';

function NotificationBadge() {
// 👉 定义一堆状态
const [count, setCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// 👉 手动控制各个状态
const fetchCount = async () => {
try {
setIsLoading(true);
const count = await getUnreadNotificationCount();
setCount(count);
setError(null);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};

fetchCount();
}, []);

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading notifications</div>;

return <div>Unread: {count}</div>;
}

如果这个接口在多个地方使用,我们需要封装为一个自定义 hook:

// 👉 创建自定义 hook
import { useState, useEffect } from 'react';

export function useUnreadNotificationCount() {
const [count, setCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const fetchCount = async () => {
try {
setIsLoading(true);
const count = await getUnreadNotificationCount();
setCount(count);
setError(null);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};

fetchCount();
}, []);

return [count, isLoading, error];
}

function NotificationBadge() {
const { count, isLoading, error } = useUnreadNotificationCount();

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading notifications</div>;

return <div>Unread: {count}</div>;
}

使用 RQ

使用 RQ 之后:

// 1. 用 RQ 封装自定义 hook
import { useQuery } from '@tanstack/react-query';
export const useUnreadNotificationCount = () => {
return useQuery({
queryKey: ['notification', 'unread-count'],
queryFn: getUnreadNotificationCount,
});
};

// 2. 引用该 hook
function NotificationBadge() {
const { data: count, isLoading, error } = useUnreadNotificationCount();

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading notifications</div>;

return <div>Unread: {count}</div>;
}

RQ 相当于语法糖,简化了自定义 hook 的封装,可以大大减少我们写样板代码。

功能二:减少重复请求

在我们的通知页中,中间菜单和右侧内容都有标题【点赞】以及未读数【3】,这里的未读数都是通过接口获取:

不使用 RQ

那么两个地方引用了相同的数据,没有 RQ 的情况下,处理方式有:

状态提升。把请求接口提升到他们的共同的父组件中。

  • 优点: 简单直接,单一数据源。
  • 缺点: 父组件需要知道子组件的数据需求
function NotificationPage() {
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
fetchUnreadCount();
}, []);

const fetchUnreadCount = async () => {
try {
setIsLoading(true);
const count = await getUnreadNotificationCount();
setUnreadCount(count);
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
};

return (
<div>
<MiddleMenu unreadCount={unreadCount} />
<RightContent unreadCount={unreadCount} onRefresh={fetchUnreadCount} />
</div>
);
}

Context API:使用 <Context> 组件,跨层级共享数据。

  • 优点: 避免 Props 穿透(props drilling),组件解耦。
  • 缺点: 任何 context 值变化会导致所有消费者重新渲染,需要额外的 Provider 包裹
const NotificationContext = createContext();

function NotificationProvider({ children }) {
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);

const fetchUnreadCount = async () => {
try {
setIsLoading(true);
const count = await getUnreadNotificationCount();
setUnreadCount(count);
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
};

useEffect(() => {
fetchUnreadCount();
}, []);

return (
<NotificationContext.Provider value={{ unreadCount, isLoading, refetch: fetchUnreadCount }}>
{children}
</NotificationContext.Provider>
);
}

// 使用
function MiddleMenu() {
const { unreadCount, isLoading } = useContext(NotificationContext);
return <div>未读: {unreadCount}</div>;
}

function RightContent() {
const { unreadCount, refetch } = useContext(NotificationContext);
return <div>未读: {unreadCount}</div>;
}

封装 Hook:把请求封装到 hook 中

  • 优点:共享请求状态
  • 缺点:每个组件都会重复请求

使用 RQ

使用 RQ 之后:

function useUnreadNotificationCount() {
return useQuery({
queryKey: ['notificationCount'],
queryFn: getUnreadNotificationCount,
});
}

function MiddleMenu() {
const { data: unreadCount, refresh } = useUnreadNotificationCount();
return <div>未读: {unreadCount}</div>;
}

function RightContent() {
const { data: unreadCount } = useUnreadNotificationCount();
return <div>未读: {unreadCount}</div>;
}

优点:自动去重: 两个组件同时挂载只发 1 次请求(queryKey 相同)
无需 Provider: 不需要包裹父组件
组件独立: 每个组件独立使用,不依赖父组件传递

这里提到"两个组件同时挂载只会发起 1 次请求",其原理就是 RQ 会缓存请求的数据。RQ 的很多功能都是基于缓存来操作。

功能三:重刷新

还是通知的例子,当用户打开"点赞"页面,在这里查看消息的时候,【未读数】需要动态更新:

不使用 RQ

没有 RQ 的情况下,我们需要对这么多个组件做数据联动,方案还是状态提升、Context,当然,还可以通过事件总线(EventEmitter)的方式:

// 定义一个事件总线通用方法:eventBus.js
class EventBus {
constructor() {
this.events = {};
}

on(event, callback) {
if (!this.events[event]) this.events[event] = [];
this.events[event].push(callback);
}

emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data));
}
}

off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
}
}

export const eventBus = new EventBus();


// 对于左侧菜单
function LeftMenu() {
const [totalUnreadCount, setTotalUnreadCount] = useState(0);

useEffect(() => {
const fetchCount = async () => {
const count = await getUnreadTotalNotificationCount();
setTotalUnreadCount(count);
};

fetchCount();

// ⚠️ 监听刷新事件
const handleRefresh = () => fetchCount();
eventBus.on('notification:read', handleRefresh);

return () => {
eventBus.off('notification:read', handleRefresh);
};
}, []);

return <div>通知 ({totalUnreadCount})</div>;
}

// 对于中间的菜单
function MiddleMenu() {
const [unreadCount, setUnreadCount] = useState(0);

useEffect(() => {
const fetchCount = async () => {
const count = await getUnreadNotificationCount();
setUnreadCount(count);
};

fetchCount();

// ⚠️ 监听刷新事件
const handleRefresh = () => fetchCount();
eventBus.on('notification:read', handleRefresh);

return () => {
eventBus.off('notification:read', handleRefresh);
};
}, []);

return <div>点赞 ({unreadCount})</div>;
}

// 对于右侧内容
function RightContent() {
const handleMarkAsRead = async (id) => {
await markNotificationAsRead(id);

// ⚠️ 触发刷新事件
eventBus.emit('notification:read');
};

return (
<button onClick={() => handleMarkAsRead(123)}>
标记已读
</button>
);
}

使用 RQ

使用 RQ,利用它的 invalidateQueries() 方法可以实现同样的效果:

// 对于左侧菜单
function LeftMenu() {
const { data: unreadTotalCount, refresh } = useQuery({
queryKey: ['totalNotificationCount'],
queryFn: getTotalUnreadNotificationCount,
staleTime: Infinite,
});

return <div>点赞 ({unreadCount})</div>;
}


// 对于中间的菜单
function MiddleMenu() {
const { data: unreadCount } = useQuery({
queryKey: ['notificationCount'],
queryFn: getUnreadNotificationCount,
});

return <div>点赞 ({unreadCount})</div>;
}

// 对于右侧内容
function RightContent() {
const queryClient = useQueryClient();
const { data: unreadCount } = useQuery({
queryKey: ['notificationCount'],
queryFn: getUnreadNotificationCount,
});

const { data: notifications } = useQuery({
queryKey: ['notifications', 'print-and-collect'],
queryFn: () => getNotifications('print-and-collect'),
});

const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
// ✅ 自动让所有相关查询失效并重新获取
queryClient.invalidateQueries({ queryKey: ['totalNotificationCount'] });
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});

return (
<div>
<h2>点赞 (未读: {unreadCount})</h2>
{notifications?.map(n => (
<div key={n.id}>
{n.content}
{!n.isRead && (
<button onClick={() => markAsReadMutation.mutate(n.id)}>
标记已读
</button>
)}
</div>
))}
</div>
);
}

优势: ✅ 零配置自动同步: invalidateQueries 一个方法搞定
组件完全解耦: <RightMenu><MiddleMenu><RightContent> 互不依赖
数据一致性: 始终从服务器获取最新数据
无需手动管理: 不需要回调、Context、事件总线
乐观更新: 可以先更新 UI,再同步服务器

RQ 底层也是使用发布/订阅(pub/sub)设计模式。

功能四:乐观更新

什么是乐观更新?

乐观更新对应的是悲观更新,悲观更新也就是比较传统的做法——先请求 API,再更新 UI。
乐观更新是先更新 UI,再请求 API

✅ 适合场景❌ 不适合场景
点赞/收藏(高概率成功)支付、转账等关键操作
标记已读/未读复杂的业务逻辑(服务器可能拒绝)
切换开关状态数据结构复杂,难以预测结果
简单的增删改操作需要服务器计算的数据(如库存扣减)
用户期望即时反馈的操作

了解了乐观更新,我们来引用它去优化上一节提到的内容,先更新未读数的 UI,再请求 API:

const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,

// ✅ 乐观更新:立即更新 UI,不等待服务器响应
onMutate: async (notificationId) => {
// 取消正在进行的查询
await queryClient.cancelQueries({ queryKey: ['notificationCount'] });

// 保存之前的值(用于回滚)
const previousCount = queryClient.getQueryData(['notificationCount']);

// 立即更新未读数
queryClient.setQueryData(['notificationCount'], (old) => old - 1);

return { previousCount };
},

// ❌ 如果失败,回滚
onError: (err, variables, context) => {
queryClient.setQueryData(['notificationCount'], context.previousCount);
},

// ✅ 无论成功失败,最终都重新获取确保数据一致
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
},
});

DevTools

RQ 提供了一个 Devtools,可以作为调试使用,可以提升我们的开发效率。

可以在项目本地开发环境下集成 Devtools,在右下角这个浮动按钮:

TODO

对于 Devtools 来说,比较好用的主要有 Actions 和 Data Explorer,可以手动查看相关数据,也可以触发 RQ 的相关方法:

TODO

深入学习

  1. 中文文档
    目前官方文档只有英文,目前有挺多的相关中文文档镜像,比如:
    https://tanstack.com.cn/query/latest/docs/framework/react/overview
    也可以自行 google "tanstack 中文文档"

  2. 沉浸式翻译
    一款 web 插件,一键把英文翻译成中文,原汁原味,适合 RQ 原文档学习。
    https://chromewebstore.google.com/detail/bpoadfkcbjbfhfodiogcnhhhpibjhbnh?utm_source=item-share-cb

  3. Zread
    如果想进一步了解 RQ 的源码或者功能,可以使用 Zread,它已经索引了 RQ 的 GitHub 仓库,并且提供 AI 问答,目前免费: https://zread.ai/TanStack/query

扩展:服务端组件中如何使用?

留一个问题

TypeScript 为什么要增加一个 satisfies?

· 4 min read
吴楷鹏
前端开发工程师

最近,在很多依赖库的类型定义文件中,经常能看到了一个陌生的朋友:satisfies

相信很多人都和我一样,看完 TypeScript 的相关文档,对这个关键字还是一头浆糊。

satisfies 关键字是 TypeScript 4.9 版本引入的,用于类型断言

先看一下连接数据库的例子:

type Connection = {}

declare function createConnection(
host: string,
port: string,
reconnect: boolean,
poolSize: number,
): Connection;

这里,我们声明了一个函数 createConnection,它接收四个参数,返回一个 Connection 类型。

接着:

type Config = {
host: string;
port: string | number;
tryReconnect: boolean | (() => boolean);
poolSize?: number;
}

我们又声明了一个 Config 类型,它包含了四个属性:hostporttryReconnectpoolSize

接下来:

const config: Config = {
host: "localhost",
port: 3000,
tryReconnect: () => true,
}

我们声明了一个 config 变量,它包含这三个属性的值:hostporttryReconnect

OK,现在我们来调用 createConnection 函数,并传入 config 参数:

function main() {
const { host, port, tryReconnect, poolSize } = config;
const connection = createConnection(host, port, tryReconnect, poolSize);
}

问题出现了:

port 类型错误

这里 port 的类型是 string | number,而 createConnection 函数的参数类型是 string,所以会报错。

为了解决类型定义问题,我们需要加上类型断言的逻辑代码:

function main() {
let { host, port, tryReconnect, poolSize } = config;

if (typeof port === "number") {
port = port.toString();
}

const connection = createConnection(host, port, tryReconnect, poolSize);
}

port 类型正确了,但 tryReconnect 类型错误了:

tryReconnect 类型错误

我们一次性将这些类型修复:

function main() {
let { host, port, tryReconnect, poolSize } = config;

if (typeof port === "number") {
port = port.toString();
}
if (typeof tryReconnect === "function") {
tryReconnect = tryReconnect();
}
if (typeof poolSize === "undefined") {
poolSize = 10;
}

const connection = createConnection(host, port, tryReconnect, poolSize);
}

porttryReconnectpoolSize 都进行了类型断言,问题解决了。

但是,这样写起来很麻烦,有没有更简单的方法呢?

一种方式是,去掉 config 的类型定义,放飞自我,让它自动被推断:

const config = {
host: "localhost",
port: 3000,
tryReconnect: () => true,
}

这样,我们可以一步到位:

function main() {
let { host, port, tryReconnect } = config;

const connection = createConnection(host, port.toString(), tryReconnect(), 10);
}

但这样放飞类型,会引起另外的错误,比如 config 随便添加一个属性:

const config = {
host: "localhost",
port: 3000,
tryReconnect: () => true,
pool: 10, // 新增了一个属性
}

这样 TypeScript 是一点都不会报错,但却会埋下隐藏炸弹,在代码上线的时候,可能会抓马,为什么 poorSize 不生效?

层层排查,最后才发现原来 poolSize 写错成了 pool

这个时候,satisfies,千呼万唤始出来:

const config = {
host: "localhost",
port: 3000,
tryReconnect: () => true,
pool: 10,
} satisfies Config;

pool 类型错误

不负众望,TypeScript 终于报错,告诉我们 pool 属性不存在。

satisfies 关键字为我们提供了一种两全其美的解决方案:

  1. 保证类型安全:它会检查我们的对象结构是否满足(satisfies)指定的类型(如 Config)。如果你写了多余的属性(如 pool),或者属性类型不匹配,TypeScript 会立刻报错。这避免了“放飞自我”带来的隐患。
  2. 保留原始类型:与使用类型注解 (: Config) 不同,satisfies 不会改变变量被推断出的原始具体类型。变量 configport 属性类型仍然是 numbertryReconnect 属性类型仍然是 () => boolean

总结来说,satisfies 的核心优势在于:在不丢失(泛化)原始推断类型的前提下,对该值进行类型检查。

这使得我们既能获得编辑器对于具体类型的智能提示和类型推断的好处,又能确保这个值的结构符合我们预先定义好的更宽泛的类型约束,从而写出更安全、更灵活的代码。

REFERENCES

How to open multiple Chrome instances at once?

· 3 min read

Hi, I'm Kaipeng.

Today I want to share a small Chrome tip: you can spin up multiple clean, isolated Chrome instances at once to make your development smoother.

Quick poll: which browsers do you usually use for development?

  • Chrome
  • Firefox
  • Safari
  • Other

My daily driver for development is Chrome.

Chrome DevTools are powerful and cover the vast majority of frontend debugging needs.

But one thing has long bothered me: my everyday browsing and development are coupled together.

For example, I have lots of extensions installed:

Chrome Extensions

These extensions can affect development because they may inject HTML or CSS into pages and generate extra network requests, which interferes with debugging.

For example, the sidebar HTML injected by an extension:

Chrome Layer Tab

At this point, the usual choices are either opening an Incognito window or switching to another browser.

Both are fine, but Incognito still uses the same Chrome instance, and every time you reopen it, all state is cleared.

Switching to another browser is another option—I tried it but eventually gave up. It’s like moving to a brand-new dev environment: different UI, habits, shortcuts, etc. It feels awkward.

Recently I learned a better way: create separate Chrome instances by using different user data directories.

Run:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --user-data-dir="/tmp/chrome_user_dir_1"

You’ll get a brand-new Chrome instance whose settings, extensions, history, etc. are all isolated.

Create Chrome Instance

You’ll even see two Chrome icons in the Dock:

Chrome Instances in Dock

This newly created instance is effectively a separate Chrome browser.

You can change its theme to distinguish it from other instances:

Modify Theme

Or sign into a different account, etc.—it’s your second Chrome.

By running this command, you can theoretically create unlimited Chrome instances—just change the --user-data-dir parameter, for example:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --user-data-dir="/tmp/chrome_user_dir_2"
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --user-data-dir="/tmp/chrome_user_dir_3"
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --user-data-dir="/tmp/chrome_user_dir_4"
......

In practice, I usually keep two instances and switch between them: one for casual browsing, one for development and debugging.

If launching a new Chrome instance every time during development feels a bit annoying, consider adding the command to your frontend project’s package.json scripts:

  "scripts": {
"dev": "next dev --turbopack",
"open-chrome": "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --args --user-data-dir=/tmp/ChromeNewProfile http://localhost:3000",
"dev:chrome": "npm run open-chrome && npm run dev"
},

Now you can run npm run dev:chrome to open the Chrome instance and automatically start next dev.

Windows PowerShell users can use:

 "scripts": {
"dev": "next dev --turbopack",
"open-chrome": "powershell -Command \\\"Start-Process 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe' -ArgumentList '--user-data-dir=D:\\\\temp\\\\ChromeNewProfile', 'http://localhost:3000'\\\"",
"dev:chrome": "npm run open-chrome && npm run dev"
},

If you want the Chrome instance to open localhost:3000 automatically so you can see the page right away, append http://localhost:3000 to the command:

{
"scripts": {
"dev": "next dev",
"dev:chrome": "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --user-data-dir=\\\"/tmp/chrome_user_dir_1\\\" http://localhost:3000 && npm run dev"
}
}

That’s it for this post. If it helped, feel free to like, bookmark, and share.

I’m Kaipeng—see you next time.

Next.js Route Navigation Progress Bar: Enhancing User Experience with BProgress

· 5 min read
Wu Kaipeng
Frontend Developer

This tutorial is also available as a video on YouTube 👉 【Next.js】Route Navigation Progress Bar

Hello, I'm Kaypen.

Let's start with a bad example.

In Dify.ai, when you click to navigate to another page, there's a waiting period before the page actually transitions.

Dify.ai lacks progress feedback during page navigation

However, during this waiting time, I don't know whether the navigation was successful or not, so I end up clicking multiple times until the page finally changes.

This is a poor user experience 👎

The solution is simple. Let's look at how GitHub handles navigation interactions.

GitHub's progress bar effect during page navigation

As you can see, GitHub displays a progress bar during navigation, clearly telling users - "I'm navigating, please wait."

So how can we implement this effect in Next.js?

We can achieve this using the BProgress library.

BProgress official website homepage

BProgress is a lightweight progress bar component library that supports Next.js 15+, as well as other frameworks like Remix and Vue.

For using BProgress, I've created a demo project nextjs-progress-bar-demo. Let's clone this project first:

git clone git@github.com:wukaipeng-dev/nextjs-progress-bar-demo.git

Then enter the project directory:

cd nextjs-progress-bar-demo

First, install the dependencies:

npm install @bprogress/next

Start the project:

npm run dev

Next.js progress bar demo project interface

As you can see, this is a simple Next.js project with three pages: Home, Login, and Register.

The main branch already has the progress bar configured. Let's switch to the without-progress-bar-demo branch:

git checkout without-progress-bar-demo

In this branch, we haven't configured the progress bar, so no progress bar will be displayed during page navigation.

Next, let's import ProgressProvider in the root layout app/layout.tsx:

'use client';

import "./globals.css";
import { ProgressProvider } from '@bprogress/next/app';

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ProgressProvider
height="4px"
color="#4c3aed"
options={{ showSpinner: false }}
shallowRouting
>
{children}
</ProgressProvider>
</body>
</html>
);
}

Now, we can see that when navigating between the home page and login page, or between login and register pages, a progress bar will be displayed.

Page navigation effect after integrating BProgress

The ProgressProvider parameters are:

  • height: The height of the progress bar
  • color: The color of the progress bar
  • options: Progress bar configuration. Here showSpinner is set to false, meaning no animated loading icon will be displayed.
  • shallowRouting: Whether to enable shallow routing. If enabled, when only the route's query parameters change (e.g., ?page=1 to ?page=2), the progress bar won't reload.

However, after successful login, clicking to navigate won't show the progress bar.

Progress bar not showing when using router.push

This is because navigation between the home page and login page, or between login and register pages, uses the <Link> component.

The <Link> component actually renders as an <a> tag, and BProgress adds click events to all <a> components to show the progress bar.

We can check in DevTools → Elements → <a> → Event Listeners whether a click event has been added:

Viewing Link component's click event listeners in DevTools

But after successful login, we use router.push for navigation.

BProgress doesn't add click events to router.push, so naturally it won't show a progress bar.

Don't worry, BProgress provides us with a useRouter method.

Replace Next.js's useRouter with the useRouter provided by BProgress:

// import { useRouter } from 'next/navigation';
import { useRouter } from '@bprogress/next/app';

Then use it as normal:

const router = useRouter();

router.push('/');

Now you can see that after successful login, when automatically navigating to the home page, the progress bar displays correctly.

Progress bar displays correctly after using BProgress's useRouter

But if your project has already wrapped its own useRouter, you can pass the wrapped useRouter as a parameter customRouter for a second wrapping:

import { useRouter } from '@bprogress/next/app';
import { useRouter as useNextIntlRouter } from '@/i18n/navigation';

export default function Home() {
const router = useRouter({
customRouter: useNextIntlRouter,
});

return (
<button
onClick={() =>
router.push('/about', {
startPosition: 0.3,
locale: 'en',
})
}
>
Go to about page
</button>
);
}

Finally, let's go back to app/layout.tsx, where we imported ProgressProvider but turned app/layout into a client component. Let's extract ProgressProvider elsewhere and keep app/layout as a server component.

// app/components/ProgressWrapper.tsx
'use client';

import { ProgressProvider } from '@bprogress/next/app';

interface ProgressWrapperProps {
children: React.ReactNode;
}

export function ProgressWrapper({ children }: ProgressWrapperProps) {
return (
<ProgressProvider
height="4px"
color="#0000ff"
options={{ showSpinner: false }}
shallowRouting
>
{children}
</ProgressProvider>
);
}

In app/layout.tsx, we import ProgressWrapper:

import { ProgressWrapper } from './components/ProgressWrapper';

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ProgressWrapper>
{children}
</ProgressWrapper>
</body>
</html>
);
}

Great job! You've completed the integration of a route navigation progress bar in Next.js.

That's all for this tutorial. I hope you found it helpful.

Thanks for reading! 👏

Who Can Tell the Difference Between Next.js, Nest.js, and Nuxt.js?

· 2 min read

As a frontend developer, at least once in your career or studies, you'll encounter a situation where you need to distinguish between Next.js, Nest.js, and Nuxt.js.

I just had one of these moments recently.

A new colleague joined our company, and during lunch chat, I heard him mention he had worked on Next.js projects before.

Since our company's new project is based on Next.js, I thought, "Great! Our new project is saved!"

But later in the group chat, he clarified that he had actually worked on Nest.js projects.

That really threw me off.

As an ordinary developer speaking ordinary Mandarin, it's completely normal not to be able to distinguish between the pronunciations of Next /nekst/ and Nest /nest/.

The authors who named these frameworks were really "clever."

These names are like wholesale products - all following the pattern N__t.js. The last time I was this confused was with the jewelry brands "Chow Tai Fook, Chow Luk Fook, Chow Sang Sang, Luk Fook Jewellery..."

This brand piggybacking actually peaked around 2016 when frontend frameworks were exploding.

That was a time when the frontend industry was blooming with various frameworks emerging constantly.

On October 25, 2016, Next.js 1.0 was released, making its debut as an open-source project.

Next.js is based on the React framework, providing Server-Side Rendering (SSR) and Static Site Generation (SSG) capabilities, along with features like automatic code splitting and routing systems.

Just one day later, on October 26, Nuxt.js was released.

I have to say, Nuxt.js copied really fast - it's based on Vue.js and created another version of Next.js.

Nest.js came later, releasing on February 26, 2017. It's quite different from Next.js and Nuxt.js.

It's a pure Node.js backend framework - essentially the JavaScript world's Spring Boot.

All three frameworks are doing well now. Besides their own technical merits, could their success be partly due to their "trend-riding" names?

Maybe next time when developing a new framework, we should just call it Not.js.

The Best Node.js Version Manager, Even Better Than NVM

· 3 min read

Introduction to Volta

When we first started installing Node.js, we could only download the installation package from the official website to install a specific version, like 18.17.1.

But for different projects, we might need different Node.js versions, like 18.17.1 and 20.17.1.

To switch versions, you'd need to uninstall the old version, install the new one, then switch projects - extremely troublesome (pain mask).

So Node.js version managers emerged, like NVM, Volta, etc.

They support installing specific Node.js versions and allow free switching between versions.

However, NVM has some issues. For example, it can't automatically switch versions based on projects, doesn't support Windows platform (though there's an unofficial nvm-windows that works), etc.

The new generation Node.js version manager Volta solves these problems.

It can automatically switch Node.js versions based on projects and supports Mac, Windows, and Linux platforms.

Volta is built with Rust, making it faster and better.

Installing Volta

According to the installation guide, enter the following command in your terminal to install Volta:

curl -fsSL https://get.volta.sh | bash

After installation, open a new terminal and enter the following command to check the current Volta version:

volta -v
2.0.2

Congratulations, Volta is successfully installed!

Now we can use Volta to manage Node.js versions.

Enter the following command in the terminal to install Node.js:

volta install node

This command will install the latest LTS version of Node.js.

LTS: Long Term Support version.

Of course, you can also use the at symbol @ to install a specific Node.js version, for example:

volta install node@20.17.1

Project-Level Node.js Version Management

Open a Node.js project you're maintaining, like "shit-mountain", find the package.json file, and add the following content:

{
//...
"volta": {
"node": "20.17.1"
}
}

When you run npm i, Volta will look for Node.js version 20.17.1.

If it can't find it, Volta will automatically install Node.js 20.17.1, then execute npm i.

This ensures the project uses Node.js version 20.17.1.

Volta has other features, like various Volta commands - list, uninstall, etc., as well as Hooks that can specify download sources. I won't elaborate here.

Visit the Volta website for more information 👉 https://volta.sh

How to Make Your Open Source Project More Attractive? Try Adding Vercel One-Click Deploy

· 3 min read

In some well-known GitHub open source projects, you'll find support for Vercel one-click deployment. For example, NextChat, which exploded in popularity two years ago and now has 78.7k stars:

So what is Vercel? It's a modern deployment platform designed specifically for frontend developers, particularly suitable for building, previewing, and deploying static websites and frontend applications.

So, if your open source project is a static website or frontend application, consider adding Vercel one-click deploy to your README.md to increase your project's attractiveness.

Adding one-click deployment is quite simple. Vercel provides a button generator tool: deploy-button

The button generator creates three formats: Markdown, HTML, and URL - you can choose what you need.

Just a heads up, the interaction here might feel a bit odd. The form input is at the bottom of the page. For example, after filling in the Git repository URL, the Markdown link above will automatically change without any success notification. You'll need to get used to this.

The only required field is your Git repository URL:

Other options include environment variables, default project name, redirects, demos, integrations, etc. Fill these as needed, then paste the generated Markdown into your open source project's README.md:

That's the entire process - very simple!

From the user's perspective, when they click the deploy button, they'll be redirected to the Vercel website:

Users need to log in to Vercel, and Vercel will request read/write permissions for Git repositories because Vercel needs to clone the target repository and deploy based on the cloned repository.

Fill in the project name and click create:

Then just wait for completion:

Congratulations!

You can already see the preview screenshot of the successfully running website. You can also click "Continue to Dashboard" to go to the control panel, then click the domain URL to see that the website has been successfully deployed:

Looking back at the whole process, Vercel's deployment service is incredibly smooth. I didn't even need to provide framework information, run commands, etc.

So, if you think Vercel one-click deployment is a good idea, consider adding it to your project!

If needed, you can check out the example project from this article: https://github.com/wukaipeng-dev/ken

After a Year of Heavy Use, How I Pushed TickTick to Its Limits

· 6 min read

Raycast's 2024 annual statistics are out. These stats include how many times I used Raycast to open other apps throughout the year. Seeing TickTick opened over a thousand times, ranking first, was a bit surprising.

TickTick's Chinese name: 滴答清单

This year was my first year trying and gradually becoming a heavy user of TickTick. Initially, I just wanted to find a task management software. After comparing many other todo apps, TickTick's free version is what I personally consider the most generous - covering most features.

While the free aspect was tempting, it wasn't enough to win me over. What really convinced me was how it solved my long-standing pain points.

When working on tasks, I use the Pomodoro Technique, which means focusing for 25 minutes (1 Pomodoro), then taking a 5-minute short break, and after accumulating 4 Pomodoros, taking a long break - this cyclical rhythm for focus.

Previously, I bought a desktop timer clock. Students preparing for exams should be familiar with this - something like this:

This timer can count up or count down, perfect for a 25-minute Pomodoro countdown. I used this for over 2 years, buying one for home and another for the office.

It's convenient - physical buttons, one press to start a Pomodoro. But its advantage is also its disadvantage.

Physical limitations mean I can't carry a timer everywhere. More troublesome, at the end of the day, I don't know how many Pomodoros I spent, when I started them, or what tasks I worked on during each Pomodoro.

In TickTick, after adding a task, you can directly start a Pomodoro for that task, or associate tasks with ongoing Pomodoros, while adding notes. Opening the statistics page clearly shows the start and end of each Pomodoro.

This basically solved my major pain point. Another minor itch was its notification feature. I often forget to start Pomodoros, but TickTick shows focus progress explicitly in the computer's menu bar:

If cross-device sync is enabled, both phone and computer will sync current focus progress in real-time:

What pleasantly surprised me was that after enabling focus mode on mobile, it automatically enters immersive mode. Besides the flip clock mode, there's this pixel mode with particularly beautiful UI:

After TickTick solved my Big Trouble, I started exploring other features.

For the core function - task management, the first point is task categorization. I believe many people's categories are quite messy, adding a "Reading" category today, a "Project Management" category tomorrow. Once categories multiply, they become hard to manage.

Here I recommend a very practical categorization method: categorizing by "roles"

At work, the role is "Employee"; at school, it's "Student"; on the track, it's "Athlete". In any social relationship, everyone plays different roles.

In my personal TickTick categories, I have roles like Admin, Worker, Developer, Friend, Reader, etc.

This division method is very stable. Once my task categories were divided, there were almost no major changes. It's also flexible enough - for example, if you become a father, just add a "Father" role, and tasks like buying formula and changing diapers go under this role.

Categorizing by roles covers almost everything, but some roles need horizontal expansion. For instance, as an employee, you need to work on project a, project b, project c, etc. You can use tags to label tasks accordingly, distinguishing different task types under roles:

Through role division + tag system, you can basically establish an orderly and stable categorization system.

The second point is task processing. TickTick hides many thoughtful features, like setting the estimated number of Pomodoros for a task:

Or setting task progress percentage:

After selecting multiple tasks by holding Shift or Command/Control, you can batch process them:

On mobile, long-press the app icon to add tasks. The task box has a voice-to-text function in the lower right corner to speed up task addition:

Another feature is the calendar. I didn't have the habit of using it before, but recently discovered two points that made me realize calendars are amazing.

First discovery: you can use filter panels to view target tasks. Previously without filters, seeing all tasks piled together on the calendar gave me a headache:

Now using filters by list, tags, etc., you can easily view tasks corresponding to schedules:

Second discovery: calendars have a universal protocol - CalDAV, a protocol for calendar data sharing and synchronization, applicable to Android, iPhone, Windows, macOS, and all devices. Just need the calendar source to import and sync schedules anywhere.

In TickTick, by importing Feishu's CalDAV configuration, I can subscribe to all Feishu meetings and schedules:

There's also a habit function, for which I discovered three usage methods:

First is the most common positive habits:

Second is bad habits:

This is opposite to positive habits - only recording when these bad habits occur. Scenarios for recording bad habits include:

  1. Recording low-frequency, occasional bad habits
  2. When successfully cultivated as daily habits, no need for frequent recording, just record exceptions when not done

Third is data recording

Habits have built-in calendars, which can be used for data recording. For example, during weight loss, you can record daily weight:

That's my personal use of TickTick over this year. It's indeed excellent software, but undeniably has limitations. For example, the Eisenhower Matrix merely divides tasks by importance and urgency in two dimensions:

But no software in this world is perfect. When there are problems, solve them. Throughout 2024, I submitted nearly 30 bugs and feature requests to TickTick:

So, does this make me an unofficial tester + product manager? 😆