Khởi tạo dự án Mobile App với Expo, Bottom Tabs và Ecosy State Management

Hướng dẫn chi tiết cách thiết lập một dự án React Native bằng Expo từ con số không, cấu hình Navigation Bottom Tabs chuẩn xác và tích hợp giải pháp quản lý trạng thái Ecosy mạnh mẽ.

17/05/2026
12 phút đọc đọc
1

Khởi tạo dự án Expo

Bước đầu tiên để xây dựng ứng dụng là khởi tạo một dự án Expo mới. Chúng ta sẽ sử dụng Yarn kết hợp với template TypeScript trắng (blank) để có một khởi đầu sạch sẽ nhất.

# Tạo project với yarn và sử dụng template typescript
yarn create expo-app@latest expo-navigation-bottom-tabs -- --template blank-typescript

# cd vào thư mục dự án
cd expo-navigation-bottom-tabs

# Cài đặt dependencies
yarn

# Chạy dự án
yarn start

Sau khi khởi chạy thành công, bạn có thể tải ứng dụng Expo Go trên điện thoại Android hoặc iOS, quét mã QR hiển thị trên Terminal để xem kết quả trực tiếp.

Cấu trúc thư mục dự án ban đầu của bạn sẽ trông như sau:

navigation-bottom-tags/
├── assets/
│   ├── adaptive-icon.png
│   ├── favicon.png
│   └── icon.png
│   └── splash-icon.png
├── .gitignore
├── app.json
├── App.tsx
├── index.tsx
├── package.json
├── tsconfig.json
└── yarn.lock
2

Cấu hình Alias Imports

Để code được gọn gàng và tránh tình trạng ../../../ chằng chịt, chúng ta cần thiết lập Absolute Imports (Alias). Đầu tiên, hãy mở file tsconfig.json và khai báo đường dẫn paths bên trong compilerOptions:

{
  ...,
  "compilerOptions": {
    ...,
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    ...
  },
  ...
}
Lưu ý: Từ TypeScript v6 trở đi, bạn không cần phải khai báo baseUrl khi định nghĩa paths nữa. Tuy nhiên, đường dẫn alias bắt buộc phải bắt đầu bằng ./.

Tiếp theo, cài đặt plugin hỗ trợ Babel phân giải (resolve) các module này:

yarn add --dev babel-plugin-module-resolver

Tạo file cấu hình babel.config.js ở thư mục gốc và cập nhật nội dung như sau:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      [
        'module-resolver',
        {
          alias: {
            '@': './src',
          },
        },
      ],
    ],
  };
};

Để kiểm tra, hãy tạo một thư mục src và thêm file main.tsx đầu tiên của chúng ta:

import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';

export function Main() {
  return (
    <View style={styles.container}>
      <Text>Open up App.tsx to start working on your app!</Text>
      <StatusBar style="auto" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

Cuối cùng, cập nhật lại file App.tsx ngoài cùng để trỏ vào component Main vừa tạo thông qua Alias @/main:

import { Main } from '@/main';

export default function App() {
  return (
    <Main />
  );
}

Đừng quên khởi động lại server Expo để Babel nhận cấu hình mới. Nhấn Ctrl + C để tắt process và chạy lại lệnh:

yarn start
3

Cài đặt React Navigation và Bottom Tabs

Hệ thống điều hướng (Routing) là xương sống của mọi ứng dụng. Chúng ta sẽ sử dụng thư viện phổ biến nhất hiện nay là @react-navigation.

yarn add @react-navigation/native @react-navigation/bottom-tabs react-native-safe-area-context

Do @react-navigation/bottom-tabs yêu cầu thư viện lõi react-native-screens, và để đảm bảo tương thích hoàn hảo với phiên bản Expo hiện tại, bạn nên dùng lệnh npx expo install để cài đặt đích danh phiên bản phù hợp:

npx expo install react-native-screens -- --exact

Chuẩn hoá kiểu dữ liệu Route

Để khai thác sức mạnh của TypeScript, hãy tạo file src/types/route.ts và định nghĩa giao diện chuẩn cho mọi Route trong ứng dụng:

import { type ComponentType } from "react";

export interface IRoute {
  // Đây là component sẽ làm màn chình cho route
  component: ComponentType;

  // Tên, key, title của route
  name: string;
}

Tạo các Màn hình (Screens)

Tạo thư mục src/screens. Giả sử ứng dụng của chúng ta có 4 tab chính: Home, Chat, Shop và Profile. Hãy lần lượt tạo 4 file index tương ứng:

// src/screens/home/index.tsx
import { Text, View } from "react-native";

export function HomeScreen() {
  return (
    <View>
      <Text>Home screen</Text>
    </View>
  );
}
// src/screens/chat/index.tsx
import { Text, View } from "react-native";

export function ChatScreen() {
  return (
    <View>
      <Text>Chat screen</Text>
    </View>
  );
}
// src/screens/shop/index.tsx
import { Text, View } from "react-native";

export function ShopScreen() {
  return (
    <View>
      <Text>Shop screen</Text>
    </View>
  );
}
// src/screens/profile/index.tsx
import { Text, View } from "react-native";

export function ProfileScreen() {
  return (
    <View>
      <Text>Profile screen</Text>
    </View>
  );
}

Cấu hình hệ thống Routes

Tạo file cấu hình src/routes.ts để tập hợp tất cả các màn hình lại thành một danh sách quản lý tập trung:

// src/routes.ts
import { type IRoute } from "@/types/route";

import { HomeScreen } from "@/screens/home";
import { ChatScreen } from "@/screens/chat";
import { ShopScreen } from "@/screens/shop";
import { ProfileScreen } from "@/screens/profile";

export const routes: IRoute[] = [
  {
    component: HomeScreen,
    name: "Home",
  },
  {
    component: ChatScreen,
    name: "Chat",
  },
  {
    component: ShopScreen,
    name: "Shop",
  },
  {
    component: ProfileScreen,
    name: "Profile",
  },
];

Tích hợp Navigation vào App

Quay lại file src/main.tsx, chúng ta cần bọc toàn bộ ứng dụng bằng <NavigationContainer>:

import { NavigationContainer } from '@react-navigation/native';

Khởi tạo đối tượng Tab thông qua hàm createBottomTabNavigator:

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
        
const Tab = createBottomTabNavigator();

Import cấu hình routes vừa tạo:

import { routes } from "@/routes";

Bây giờ, chúng ta dùng vòng lặp để render các màn hình tự động thay vì phải khai báo thủ công từng tab một:

return (
  <NavigationContainer>
    <Tab.Navigator>
      {routes.map((route) => (
        <Tab.Screen key={route.name} name={route.name} component={route.component} />
      ))}
    </Tab.Navigator>
  </NavigationContainer>
);

Tóm lại, file src/main.tsx hoàn chỉnh của bạn lúc này sẽ trông như sau:

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';

const Tab = createBottomTabNavigator();

export function Main() {
  return (
    <NavigationContainer>
      <StatusBar style="auto" />
      <Tab.Navigator>
        {routes.map((route) => (
          <Tab.Screen key={route.name} name={route.name} component={route.component} />
        ))}
      </Tab.Navigator>
    </NavigationContainer>
  );
}

Sau khi lưu lại, bạn sẽ thấy thanh Bottom Tabs xuất hiện ở dưới cùng màn hình với 4 thẻ tương ứng.

Tuỳ biến Tab Icons nâng cao

Mặc định, cách nhanh nhất để thêm icon là khai báo chuỗi (string) đại diện cho icon từ bộ @expo/vector-icons vào thuộc tính options:

options={{tabBarIcon: ({color, size}) => <MaterialCommunityIcons name={route.icon} color={color} size={size} />}}

Tuy nhiên, cách tiếp cận này khá hạn chế nếu bạn muốn dùng icon Custom dạng SVG của riêng mình. Để giải quyết triệt để, chúng ta sẽ định nghĩa lại hàm render Icon linh hoạt hơn. Hãy cập nhật lại src/types/route.ts:

interface RouteIconProps {
  color: string;
  size: number;
  focused: boolean;
}
import type { ComponentType } from "react";

export interface RouteIconProps {
  color: string;
  size: number;
  focused: boolean;
}

export interface IRoute {
  // Đây là component sẽ làm màn chình cho route
  component: ComponentType;

  // Tên, key, title của route
  name: string;

  // Đây là icon của route, nó sẽ được render trên bottom tabs
  icon: (props: RouteIconProps) => ReactNode;
}

Bây giờ, thay vì khai báo icon chung chung, chúng ta sẽ tạo riêng một file tab-icon.tsx trong từng thư mục screen. Việc này giúp đóng gói logic hiển thị icon (active/inactive) đi kèm ngay sát với màn hình đó.

// src/screens/home/tab-icon.tsx
        
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";

export function HomeTabIcon(props: RouteIconProps) {
  const { color, size, focused } = props;
  return <MaterialCommunityIcons name={focused ? "home" : "home-outline"} color={color} size={size} />
}
// src/screens/chat/tab-icon.tsx
        
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";

export function ChatTabIcon(props: RouteIconProps) {
  const { color, size, focused } = props;
  return <MaterialCommunityIcons name={focused ? "message" : "message-outline"} color={color} size={size} />
}
// src/screens/shop/tab-icon.tsx
        
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";

export function ShopTabIcon(props: RouteIconProps) {
  const { color, size, focused } = props;
  return <MaterialCommunityIcons name={focused ? "cart" : "cart-outline"} color={color} size={size} />
}
// src/screens/profile/tab-icon.tsx
        
import { RouteIconProps } from "@/types/route";
import { MaterialCommunityIcons } from "@expo/vector-icons";

export function ProfileTabIcon(props: RouteIconProps) {
  const { color, size, focused } = props;
  return <MaterialCommunityIcons name={focused ? "account" : "account-outline"} color={color} size={size} />
}

Để giữ file cấu hình routes.ts được sạch sẽ, hãy re-export các icon này ngay trong file index.tsx của từng screen:

// src/screens/home/index.tsx

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

export { HomeTabIcon } from "./tab-icon";

export function HomeScreen() {
  return (
    <View>
      <Text>Home screen</Text>
    </View>
  );
}
// src/screens/chat/index.tsx

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

export { ChatTabIcon } from "./tab-icon";

export function ChatScreen() {
  return (
    <View>
      <Text>Chat screen</Text>
    </View>
  );
}
// src/screens/shop/index.tsx

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

export { ShopTabIcon } from "./tab-icon";

export function ShopScreen() {
  return (
    <View>
      <Text>Shop screen</Text>
    </View>
  );
}
// src/screens/profile/index.tsx

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

export { ProfileTabIcon } from "./tab-icon";

export function ProfileScreen() {
  return (
    <View>
      <Text>Profile screen</Text>
    </View>
  );
}

Nhờ cấu trúc trên, file src/routes.ts giờ đây cực kỳ gọn gàng và dễ bảo trì:

// src/routes.ts

import { type IRoute } from "@/types/route";

import { HomeScreen, HomeTabIcon } from "@/screens/home";
import { ChatScreen, ChatTabIcon } from "@/screens/chat";
import { ShopScreen, ShopTabIcon } from "@/screens/shop";
import { ProfileScreen, ProfileTabIcon } from "@/screens/profile";

export const routes: IRoute[] = [
  {
    component: HomeScreen,
    name: "Home",
    icon: HomeTabIcon
  },
  {
    component: ChatScreen,
    name: "Chat",
    icon: ChatTabIcon
  },
  {
    component: ShopScreen,
    name: "Shop",
    icon: ShopTabIcon,
  },
  {
    component: ProfileScreen,
    name: "Profile",
    icon: ProfileTabIcon,
  },
];

Và ở file src/main.tsx, bạn chỉ cần gán thẳng hàm route.icon vào tabBarIcon:

// src/main.tsx

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';

const Tab = createBottomTabNavigator();

export function Main() {
  return (
    <NavigationContainer>
      <StatusBar />
      <Tab.Navigator>
        {routes.map((route) => (
          <Tab.Screen
            key={route.name}
            name={route.name}
            component={route.component}
            options={{
              tabBarIcon: route.icon,
            }} 
          />
        ))}
      </Tab.Navigator>
    </NavigationContainer>
  );
}
Tip kiến trúc: Việc tạo mỗi screen thành một thư mục riêng chứa index.tsx và các file phụ trợ như tab-icon.tsx giúp cô lập logic cực tốt. Thư mục src/screens chỉ chứa các thành phần "độc quyền" của trang đó, còn những gì dùng chung sẽ được đưa vào src/components.
4

Quản lý Theme với Ecosy State Management

Tiếp theo, chúng ta sẽ tích hợp hệ sinh thái Ecosy để quản lý State và Theme cho toàn bộ ứng dụng. Cài đặt các package cần thiết:

yarn add @ecosy/core @ecosy/store @ecosy/react @ecosy/styled

Bộ tứ quyền lực của Ecosy bao gồm:

  • @ecosy/core: Cung cấp các tiện ích nền tảng (Utilities) như Http, Subscriber (Pub/Sub pattern), Serialize, Slugify,...
  • @ecosy/store: Core State Management độc lập, không phụ thuộc React. Kế thừa cơ chế Pub/Sub giúp state mang tính phản ứng (reactive) tức thì.
  • @ecosy/react: Cầu nối giữa Store và React thông qua hook useSelector (Tương tự Redux Toolkit) để tự động re-render component khi dữ liệu thay đổi.
  • @ecosy/styled: Hệ thống Styled Components kèm theo công cụ quản lý Theme tích hợp sẵn.

Khác biệt lớn nhất của Ecosy là bạn không cần bọc ứng dụng bằng một Provider khổng lồ. Các Store hoạt động như những Singleton độc lập, gọi là có, dùng là ăn ngay.

Tích hợp Theme vào Navigator

Bộ @ecosy/styled đã tích hợp sẵn theme mặc định. Chúng ta sẽ tách Tab.Navigator ra một component riêng để tránh re-render không cần thiết và dễ dàng styling phần Header theo theme:

// src/main.tsx

import { ReactElement } from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useThemeFactory } from "@ecosy/styled/react-native";

const Tab = createBottomTabNavigator();

interface NavigatorProps {
  children: ReactElement[];
}

function Navigator(props: NavigatorProps) {
  const { children } = props;

  const screenOptions = useThemeFactory(({ palette }) => ({
    headerStyle: {
      backgroundColor: palette.primary,
    },
    headerTintColor: palette.onPrimary,
  }));

  return (
    <Tab.Navigator screenOptions={screenOptions}>
      {children}
    </Tab.Navigator>
  );
}

Đưa component Navigator vừa tạo vào cấu trúc Main:

// src/main.tsx

export function Main() {
  return (
    <NavigationContainer>
      <StatusBar style="auto" />
      <Navigator>
        {routes.map((route) => (
          <Tab.Screen
            key={route.name}
            name={route.name}
            component={route.component}
            options={{
              tabBarIcon: route.icon,
            }} 
          />
        ))}
      </Navigator>
    </NavigationContainer>
  );
}

Thành quả file src/main.tsx tổng hợp:

// src/main.tsx

import { ReactElement } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { useThemeFactory } from '@ecosy/styled/react-native';
import { StatusBar } from 'expo-status-bar';
import { routes } from './routes';

const Tab = createBottomTabNavigator();

interface NavigatorProps {
  children: ReactElement[];
}

function Navigator(props: NavigatorProps) {
  const { children } = props;

  const screenOptions = useThemeFactory(({ palette }) => ({
    headerStyle: {
      backgroundColor: palette.primary,
    },
    headerTintColor: palette.onPrimary,
  }));

  return (
    <Tab.Navigator screenOptions={screenOptions}>
      {children}
    </Tab.Navigator>
  );
}

export function Main() {
  return (
    <NavigationContainer>
      <StatusBar style="auto" />
      <Navigator>
        {routes.map((route) => (
          <Tab.Screen
            key={route.name}
            name={route.name}
            component={route.component}
            options={{
              tabBarIcon: route.icon,
            }} 
          />
        ))}
      </Navigator>
    </NavigationContainer>
  );
}

Xây dựng Base Screen Component

Để đảm bảo mọi màn hình đều có padding và background màu sắc đồng nhất theo theme hiện hành, hãy tạo một component Screen bọc ngoài (Wrapper Component):

import { PropsWithChildren } from "react";
import { makeStyles, View, ViewProps } from "@ecosy/styled/react-native";
import { StyleSheet } from "react-native";

const useStyles = makeStyles(({ palette }) => ({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
    backgroundColor: palette.background,
  },
}));

export interface ScreenProps extends ViewProps {}

export function Screen(props: PropsWithChildren<ScreenProps>) {
  const { children, style, ...rest } = props;
  const { styles } = useStyles();

  return (
    <View {...rest} style={StyleSheet.flatten([styles.container, style])}>
      {children}
    </View>
  );
}

Sau này, khi cần bổ sung KeyboardAvoidingView hay SafeAreaView, bạn chỉ cần sửa duy nhất tại component Screen này.

Phát triển Component Button đa biến (Variants)

Ecosy cung cấp hàm variants cho phép bạn xây dựng các UI component phức tạp với nhiều trạng thái khác nhau một cách dễ dàng, tương tự như Tailwind hay Stitches:

// src/components/button/index.tsx

import { forwardRef } from "react";
import { StyleSheet, View as RNView } from "react-native";
import {
  Text,
  TouchableOpacity,
  type TextProps,
  type TouchableOpacityProps,
  variants,
  useTheme
} from "@ecosy/styled/react-native";

const buttonContainerVariants = variants({
  alignItems: "center",
  justifyContent: "center",
}, {
  variants: {
    type: {
      primary: ({ palette }) => ({ backgroundColor: palette.primary }),
      secondary: ({ palette }) => ({ backgroundColor: palette.secondary }),
      outline: ({ palette, components }) => ({
        borderWidth: components.button.md.borderWidth || 1,
        borderColor: palette.primary,
        backgroundColor: "transparent",
      }),
    },
    size: {
      "2xs": ({ components }) => ({
        height: components.button["2xs"].height,
        borderRadius: components.button["2xs"].radius,
        paddingHorizontal: components.button["2xs"].paddingHorizontal,
      }),
      xs: ({ components }) => ({
        height: components.button.xs.height,
        borderRadius: components.button.xs.radius,
        paddingHorizontal: components.button.xs.paddingHorizontal,
      }),
      sm: ({ components }) => ({
        height: components.button.sm.height,
        borderRadius: components.button.sm.radius,
        paddingHorizontal: components.button.sm.paddingHorizontal,
      }),
      md: ({ components }) => ({
        height: components.button.md.height,
        borderRadius: components.button.md.radius,
        paddingHorizontal: components.button.md.paddingHorizontal,
      }),
      lg: ({ components }) => ({
        height: components.button.lg.height,
        borderRadius: components.button.lg.radius,
        paddingHorizontal: components.button.lg.paddingHorizontal,
      }),
      xl: ({ components }) => ({
        height: components.button.xl.height,
        borderRadius: components.button.xl.radius,
        paddingHorizontal: components.button.xl.paddingHorizontal,
      }),
      "2xl": ({ components }) => ({
        height: components.button["2xl"].height,
        borderRadius: components.button["2xl"].radius,
        paddingHorizontal: components.button["2xl"].paddingHorizontal,
      }),
    },
    fullWidth: {
      true: { width: "100%" },
      false: { alignSelf: "flex-start" },
    }
  },
  defaultVariants: {
    type: "primary",
    size: "md",
    fullWidth: "true",
  }
});

const buttonTextVariants = variants({
  fontWeight: "700",
}, {
  variants: {
    type: {
      primary: ({ palette }) => ({ color: palette.onPrimary }),
      secondary: ({ palette }) => ({ color: palette.onSecondary }),
      outline: ({ palette }) => ({ color: palette.primary }),
    },
    size: {
      "2xs": ({ components }) => ({ fontSize: components.button["2xs"].fontSize }),
      xs: ({ components }) => ({ fontSize: components.button.xs.fontSize }),
      sm: ({ components }) => ({ fontSize: components.button.sm.fontSize }),
      md: ({ components }) => ({ fontSize: components.button.md.fontSize }),
      lg: ({ components }) => ({ fontSize: components.button.lg.fontSize }),
      xl: ({ components }) => ({ fontSize: components.button.xl.fontSize }),
      "2xl": ({ components }) => ({ fontSize: components.button["2xl"].fontSize }),
    }
  },
  defaultVariants: {
    type: "primary",
    size: "md",
  }
});

export interface ButtonProps extends TouchableOpacityProps {
  components?: {
    text?: TextProps;
  };
  type?: "primary" | "secondary" | "outline";
  size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
  fullWidth?: boolean;
}

export const Button = forwardRef<RNView, ButtonProps>(
  function Button(props, ref) {
    const { style, children, components = {}, type = "primary", size = "md", fullWidth = true, ...rest } = props;
    const theme = useTheme();

    const containerStyle = buttonContainerVariants(theme, { type, size, fullWidth: fullWidth ? "true" : "false" });
    const textStyle = buttonTextVariants(theme, { type, size });

    return (
      <TouchableOpacity
        {...rest}
        ref={ref}
        style={StyleSheet.flatten([containerStyle, style])}
      >
        <Text {...components.text} style={StyleSheet.flatten([textStyle, components.text?.style])}>
          {children}
        </Text>
      </TouchableOpacity>
    );
  }
);

Giờ đây, chúng ta có thể áp dụng ngay Button đa năng này vào màn hình Profile để tạo tính năng chuyển đổi giao diện (Toggle Theme) giữa chế độ Sáng và Tối:

// src/screens/profile/switch-theme.tsx

import { View, actions } from "@ecosy/styled/react-native";
import { Button } from "@/components/button";

export function SwitchTheme() {
  return (
    <View py={8}>
      <Button onPress={actions.toggleTheme}>
        Toggle theme
      </Button>
    </View>
  );
}

Tiếp theo, hãy nhúng component SwitchTheme vừa tạo vào cấu trúc của màn hình ProfileScreen:

// src/screens/profile/index.tsx

import { SwitchTheme } from "./switch-theme";
import { Screen } from "@/components/screen";
import { Text } from "@ecosy/styled/react-native";

export function ProfileScreen() {
  return (
    <Screen>
      <Text>Profile screen</Text>
      <SwitchTheme />
    </Screen>
  );
}

Chỉ với vài dòng code đơn giản, khi truy cập vào tab Profile và nhấn nút Toggle Theme, toàn bộ màu nền và chữ của ứng dụng sẽ lập tức biến đổi mượt mà theo cấu hình theme tương ứng nhờ sức mạnh của Ecosy Store.

Tuy nhiên, thanh điều hướng dưới cùng (Bottom Tabs) mặc định vẫn chưa đồng bộ màu sắc. Để giải quyết vấn đề này, giải pháp tối ưu nhất là tạo một Custom TabBar hoàn toàn mới, kế thừa màu sắc linh hoạt từ @ecosy/styled:

// src/components/tabbar/index.tsx

import { makeStyles, Text, TouchableOpacity, View } from "@ecosy/styled/react-native";
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";

const useStyles = makeStyles(({ palette }) => ({
  container: {
    flexDirection: "row",
    height: 90,
    borderTopWidth: 1,
    borderTopColor: palette.border,
    backgroundColor: palette.surface,
    paddingBottom: 22,
  },
  tab: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    paddingVertical: 8,
  },
  label: {
    fontSize: 12,
    fontWeight: "600",
    marginTop: 4,
  },
  icon: {
    marginBottom: 2,
  },
}));

export function TabBar(props: BottomTabBarProps) {
  const { state, descriptors, navigation } = props;
  const { styles, theme } = useStyles();
  const colors = theme.palette;

  return (
    <View style={styles.container}>
      {state.routes.map((route, index) => {
        const { options } = descriptors[route.key];
  
        const focused = state.index === index;
        const label = options.tabBarLabel !== undefined
          ? options.tabBarLabel
          : options.title !== undefined 
            ? options.title
            : route.name;

        const onPress = () => {
          const event = navigation.emit({
            type: "tabPress",
            target: route.key,
            canPreventDefault: true,
          });

          if (!focused && !event.defaultPrevented) {
            navigation.navigate(route.name);
          }
        };

        const renderedLabel = typeof label === "function" ? label({
          focused,
          color: focused ? colors.primary : colors.text,
          position: "below-icon",
          children: options.title || "",
        }) : label;

        return (
          <TouchableOpacity key={route.key} onPress={onPress} style={styles.tab}>
            {options.tabBarIcon?.({
              focused,
              color: focused ? colors.primary : colors.textSecondary,
              size: 24
            }) || null}
            <Text c={focused ? colors.primary : colors.text} style={styles.label}>
              {renderedLabel}
            </Text>
          </TouchableOpacity>
        );
      })}
    </View>
  );
}

Cuối cùng, tích hợp TabBar tùy chỉnh này vào cấu trúc Navigator trong main.tsx:

Chú ý: tabBar không phải là react component context do đó không thể truyền trực tiếp mà phải dùng hàm để bao bọc nó lại.
// src/main.tsx

import { TabBar } from "@/components/tabbar";

function Navigator(props: NavigatorProps) {
  const { children } = props;

  const screenOptions = useThemeFactory(({ palette }) => ({
    headerStyle: {
      backgroundColor: palette.primary,
    },
    headerTintColor: palette.onPrimary,
  }));

  return (
    <Tab.Navigator
      tabBar={(props) => <TabBar {...props} />}
      screenOptions={screenOptions}
    >
      {children}
    </Tab.Navigator>
  );
}

Mặc định, @ecosy/styled khởi tạo ứng dụng bằng bộ màu slate tinh tế. Tuy nhiên, bạn hoàn toàn có thể thổi luồng gió mới cho ứng dụng bằng cách import các bảng màu khác được thiết kế sẵn (ví dụ: red, emerald, amber...):

// src/main.tsx

import { useThemeFactory, actions } from "@ecosy/styled/react-native";
import red from "@ecosy/styled/theme/red";

actions.setThemes(red);

Tuyệt vời hơn nữa, sự linh hoạt của @ecosy/styled không chỉ dừng lại ở các theme có sẵn. Bạn hoàn toàn có quyền tự do định nghĩa các bảng màu mang đậm bản sắc thương hiệu (Custom Theme) của dự án và nạp vào hệ thống:

// src/theme/brand.ts

import { type ThemeDefinition } from "@ecosy/styled";
import { defaultComponents } from "@ecosy/styled/theme/default";

export const brandTheme: ThemeDefinition = {
  light: {
    palette: {
      primary: "#E11D48",
      onPrimary: "#FFFFFF",
      background: "#FFF1F2",
      surface: "#FFFFFF",
      text: "#1F2937",
      // ... định nghĩa các token màu khác
    },
    components: defaultComponents
  },
  dark: {
    palette: {
      primary: "#F43F5E",
      onPrimary: "#FFFFFF",
      background: "#0F172A",
      surface: "#1E293B",
      text: "#F8FAFC",
      // ... định nghĩa các token màu khác
    },
    components: defaultComponents
  }
};
Mẹo nhỏ: Nếu bạn gọi actions.setThemes(brandTheme), Ecosy sẽ ghi đè (override) hoàn toàn cấu hình theme cũ. Tuy nhiên, nhờ cơ chế Deep Merge State thông minh, bạn không nhất thiết phải khai báo lại toàn bộ token. Chỉ cần định nghĩa những thuộc tính muốn thay đổi (ví dụ: primary) và cập nhật thông qua store.setState(), Ecosy sẽ tự động trộn (merge) và kế thừa các thuộc tính còn thiếu từ cấu hình mặc định.

Sau đó kích hoạt bản vá theme vừa tạo thông qua `store.setState`:

import { store } from "@ecosy/styled/react-native";
import { brandTheme } from "./theme/brand";

store.setState({ themes: brandTheme });

Điểm sáng giá nhất của kiến trúc này nằm ở cơ chế Pub/Sub ngầm. Bất kỳ sự thay đổi nào từ Store (như khi gọi hàm setThemes) đều ngay lập tức được truyền tải đến hệ thống State nội bộ. Ecosy sẽ tự động kích hoạt tiến trình render lại nhưng chỉ tập trung chính xác vào các component React đang đăng ký theo dõi (subscribe) màu sắc. Nhờ thiết kế tách bạch này, trải nghiệm chuyển đổi giao diện ứng dụng diễn ra chớp nhoáng mà không cần phụ thuộc vào một Context Provider khổng lồ bọc bên ngoài.


Mã nguồn tham khảo (Template)

Nếu bạn muốn bắt tay vào thực hành ngay mà không cần mất thời gian thiết lập từ đầu, bạn có thể clone trực tiếp các template repository hoàn chỉnh mà chúng tôi đã chuẩn bị sẵn: