Full Stack Development:

Type-Safe Cross Platform Applications

LFI Klub

look2023

Let's get to know each other!

Agenda

  •  8:30 -  9:00 - Coffee (30min)
  •  9:00 - 11:00 - Opening & first session (2h)
  • 11:00 - 11:30 - Coffee & sandwiches (30min)
  • 11:30 - 13:30 - Second session (2h)
  • 13:30 - 14:30 - Lunch (1h)
  • 14:30 - 16:30 - Third session (2h)
  • 16:30 - 16:45 - Coffee (15min)
  • 16:45 - 18:00- Forth session (1:15h)
  • 18:00 - 18:15 - Closing (15min)

How we work

  • Dedicated branches for each workshop stage
  • Treat everyone as a team - collaboration
  • Ask questions on loud so everyone can benefit from it
  • Work sessions crossed with small presentations

https://slides.com/czystyl/code

What does Full Stack mean to You?

Why Full Stack?

Tooling

  • Next.js 
  • Expo
  • Turborepo
  • tRPC & zod
  • Tailwind & shadcn/ui
  • Drizzle ORM
  • Clerk

The Bank App 🏦

DEMO

github.com/czystyl/the-bank

# SESSION 1

Environment Setup - branch stage-1

  1. Clone the repository 

  2. Push repo to your Github

  3. Run Web 

  4. Run Mobile

github.com/czystyl/the-bank

Continuous Integration

Github Action

name: CI

on:
  pull_request:
    branches: ["*"]
  push:
    branches: ["main"]
  merge_group:

# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

jobs:
  build-lint:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v3

      - name: Setup pnpm
        uses: pnpm/action-setup@v2.2.4

      - name: Setup Node 18
        uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Get pnpm store directory
        id: pnpm-cache
        run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT

      - name: Setup pnpm cache
        uses: actions/cache@v3
        with:
          path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-

      - name: Install deps (with cache)
        run: # install deps

Vercel Deployment

# SESSION 1

Styling 

export default function HomePage() {
  return (
    <main
      className="flex h-screen flex-1 items-center justify-center bg-gray-800"
    >
      <h1 className="text-7xl text-gray-300">
        👋 Hello!
      </h1>
    </main>
  );
}

Styling with Tailwind CS

# SESSION 1

https://tailwindcss.com

Component library for WEB

# SESSION 1

https://ui.shadcn.com

function PageReact() {
  return (
    <div>
      <h1>👋 Hello!</h1>
      {users.map((user) => (
        <div key={user.id}>
          <span>{user.name}</span>
        </div>
      ))}
      <button onClick={() => {}}>
        <span>Click me!</span>
      </button>
    </div>
  );
}

From React to Native

# SESSION 1
import { View } from 'react-native';

function ScreenReactNative() {
  return (
    <View>
      <h1>👋 Hello!</h1>
      {users.map((user) => (
        <View key={user.id}>
          <span>{user.name}</span>
        </View>
      ))}
      <button onClick={() => {}}>
        <span>Click me!</span>
      </button>
    </View>
  );
}

From React to Native

# SESSION 1
import { View, Text } from 'react-native';

function ScreenReactNative() {
  return (
    <View>
      <Text>👋 Hello!</Text>
      {users.map((user) => (
        <View key={user.id}>
          <Text>{user.name}</Text>
        </View>
      ))}
      <button onClick={() => {}}>
        <Text>Click me!</Text>
      </button>
    </View>
  );
}

From React to Native

# SESSION 1
import { View, Text, Pressable } from 'react-native';

function ScreenReactNative() {
  return (
    <View>
      <Text>👋 Hello!</Text>
      {users.map((user) => (
        <View key={user.id}>
          <Text>{user.name}</Text>
        </View>
      ))}
      <Pressable onPress={() => {}}>
        <Text>Click me!</Text>
      </Pressable>
    </View>
  );
}

From React to Native

# SESSION 1
export default function HomeScreen() {
  return (
    <View styles={styles.container}>
      <Text styles={styles.title}>👋 Hello!</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
  },
  title: {
    fontSize: 32,
    color: "lightgray",
  },
});

From React to Native

# SESSION 1
export default function HomeScreen() {
  return (
    <View className="flex flex-1 items-center justify-center">
      <Text className="text-6xl text-gray-300">👋 Hello!</Text>
    </View>
  );
}

From React to Native

* Not all styles are supported, particularly animated one

# SESSION 1
import { Text } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export default function HomeScreen() {
  return (
    <SafeAreaView
      className="flex flex-1 items-center justify-center px-4"
      edges={["top"]}
    >
      <Text
        className="text-6xl text-gray-300"
      >
        👋 Hello!
      </Text>
    </SafeAreaView>
  );
}

Styling Mobile App with NativeWind

# SESSION 1

nativewind.dev

apps/web/src/app/page.tsx
apps/web/src/app/page.tsx

apps/web/src/app/dashboard/page.tsx

*apps/web/src/app/dashboard/layout.tsx
apps/mobile/src/app/(auth)/onboarding.tsx

apps/mobile/src/app/(auth)/sign-in.tsx

apps/mobile/src/app/(home)/index.tsx

Coffee break

👮 Authentication

 

🧭 Navigation

 

💾 Database

Clerk

Authentication and User Management
for mobile and web

Auth flow

Shared workspace packages

add @the-bank/core

Pages Router? App Router?

Dashboard Page
├─ User details Page

└─ Transfer Details Page

Expo Router v2

Home Tab

└─ Transfer Screen (index)

Transfer Tab
├─ Transfer List Screen

└─ Transfer Details Screen

Expo development build

Database 💾

using Drizzle ORM

Docker & Managed DB

@the-bank/env

export const env = createEnv({
  server: {
    NODE_ENV: z.union([
      	z.literal("development"), z.literal("production"),
      ]).default("development"),
    CLERK_SECRET_KEY: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
  },
  runtimeEnv: {
    NODE_ENV: process.env.NODE_ENV,
    CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
      process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
  },
});

Schema Declaration

import { int, mysqlEnum, mysqlTable, bigint, varchar } from 'drizzle-orm/mysql-core';
 
export const countries = mysqlTable('countries', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  name: varchar('name', { length: 256 }),
});
 
export const cities = mysqlTable('cities', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  name: varchar('name', { length: 256 }),
  countryId: int('country_id').references(() => countries.id),
  popularity: mysqlEnum('popularity', ['unknown', 'known', 'popular']),
});

Select in Drizzle

const result = await db
  .select()
  .from(users)
  .leftJoin(
    transactions, 
    eq(
      transactions.recipientUserId, 
      users.clerkId
    )
  )
  .orderBy(desc(transactions.id))
  .where(
    eq(transactions.type, "INCOME")
  );
{
    users: {
      id: 56,
      clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      userName: 'Luke',      
    },
    transactions: {
      id: 3256,      
      title: 'Add founds',
      value: 1,      
      type: 'INCOME',
      senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',            
    }
  },
  {
    users: {
      id: 56,
      clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      userName: 'Luke',      
    },
    transactions: {
      id: 3255,      
      title: 'Add founds',
      value: 1,      
      type: 'INCOME',
      senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',            
    }
  },
  {
    users: {
      id: 56,
      clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      userName: 'Luke',      
    },
    transactions: {
      id: 3254,      
      title: 'Add founds',
      value: 1,      
      type: 'INCOME',
      senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',            
    }
  },
  {
    users: {
      id: 56,
      clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      userName: 'Luke',      
    },
    transactions: {
      id: 3253,      
      title: 'Add founds',
      value: 1,      
      type: 'INCOME',
      senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',            
    }
  },
  {
    users: {
      id: 56,
      clerkId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      userName: 'Luke',      
    },
    transactions: {
      id: 3252,      
      title: 'Add founds',
      value: 999,      
      type: 'INCOME',
      senderUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',
      recipientUserId: 'user_2VcaBtOYstzJ2mPxWc4E4yrqbVG',            
    }
  },

Query in Drizzle

[{
  id: 10,
  name: "Dan",
  transfert: [
    {
      id: 1,
      content: "SQL is awesome",
      authorId: 10,
    },
    {
      id: 2,
      content: "But check relational queries",
      authorId: 10,
    }
  ]
}]
const result = await db
  .query
  .users
  .findMany({	
    with: {
      transactions: true			
    }
  });

Query in Drizzle (schema)

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name'),
});

export const usersRelations = relations(users, ({ one }) => ({
  profileInfo: one(profileInfo, {
    fields: [users.id],
    references: [profileInfo.userId],
  }),
}));

export const profileInfo = pgTable('profile_info', {
  id: serial('id').primaryKey(),
  userId: integer("user_id").references(() => users.id),
  metadata: jsonb("metadata"),
});

Drizzle ORM

https://orm.drizzle.team/docs/overview

  1. packages/db/src/user.ts
    • insertUser()

    • getUser()

    • getUsers()

  2. packages/db/src/transaction.ts
    • getAccountBalance()
    • addFounds()
  3. @the-bank/scripts
    • Sync Clerk Users
    • Seed Fake Users
    • Seed Fake Transactions
pnpm script:dev

Drizzle Kit

https://orm.drizzle.team/kit-docs/overview

Drizzle Studio

https://orm.drizzle.team/drizzle-studio/overview

Lunch break

Let's connect DB and API

Application Programming Interface

REST API     SOAP API     RPC     GraphQL

https://trpc.io/

github.com/iway1/trpc-panel

Let's glue the API with Database! 

Data Fetching Expo

export default function RootLayout() {
  return (
    <ClerkProvider>
           ...
    </ClerkProvider>
  );
}

tRPC Provider Expo & Next.js

export default function RootLayout() {
  return (
    <ClerkProvider>
      <TRPCProvider>
           ...
      </TRPCProvider>
    </ClerkProvider>
  );
}

tRPC Provider Expo & Next.js

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    api.createClient({
      transformer: superjson,
      links: [
        loggerLink({
          enabled: (opts) =>
            process.env.NODE_ENV === "development" ||
            (opts.direction === "down" && opts.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    }),
  );

  return (
    <api.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <ReactQueryStreamedHydration>
          {props.children}
        </ReactQueryStreamedHydration>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </api.Provider>
  );
}

tRPC Provider Next.js

export function TRPCProvider(props: { children: ReactNode }) {
  const { getToken } = useAuth();

  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    api.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          async headers() {
            const authToken = await getToken();

            return {
              Authorization: authToken ?? undefined,
            };
          },
        }),
      ],
    }),
  );

  return (
    <api.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {props.children}
      </QueryClientProvider>
    </api.Provider>
  );
}

tRPC Provider Expo

tRCP Client with Expo
== Tanstack Query

import { api } from "~/lib/api";

export default function HomeScreen() {
  const apiUtils = api.useContext();

  const { data, isLoading } = api.user.balance.useQuery();

  const { mutate } = api.transaction.addFounds.useMutation({
    onSettled: () => {
      void apiUtils.user.balance.invalidate();
      void apiUtils.transaction.all.refetch();
    },
  });

  return (
    <Pressable
      onPress={() => {
        mutate({ value: Math.random() });
      }}
    />
  );
}

Router Input & Output type


type Balance = RouterOutputs["user"]["balance"];
//     ^? type Balance = number

type CheatInput = RouterInputs["transaction"]["addFounds"];
//     ^? type CheatInput = { value: number }

Scrollview & FlatList

<ScrollView className="pt-20">
  {dummyTransactions.map(({ transaction, sender, recipient }) => {
    return (
      <TransactionItem
        key={transaction.uuid}
        transaction={transaction}
        sender={sender}
        recipient={recipient}
      />
    );
  })}
</ScrollView>

Scroll View vs FlatList

<FlatList
  className="pt-20"
  data={data}
  keyExtractor={(item) => item.transaction.uuid}
  renderItem={({ item }) => {
    return (
      <TransactionItem
        transaction={item.transaction}
        sender={item.sender}
        recipient={item.recipient}
      />
    );
  }}
 />

Scroll View vs FlatList

const { data, isLoading, isFetching, refetch } =
    api.transaction.all.useQuery();

<FlatList
  className="pt-20"
  data={data}
  refreshing={!data && !isLoading && isFetching}
  onRefresh={refetch}
  keyExtractor={(item) => item.transaction.uuid}
  renderItem={({ item }) => {
    ...
  }}
 />

Flat List RefreshControl

Shared Transition

export default function TransactionItem({
  transaction,
  sender,
}: TransactionResult) {
  const uniqueTag =
    transaction.uuid + sender.clerkId + transaction.type;

  return (
    <Pressable
      onPress={() => {
        router.push({
          pathname: `/(home)/transactions/${transaction.uuid}`,         
        });
      }}
    >
      <Animated.Image
        source={{ uri: sender?.imageUrl }}
        style={styles.avatar}
        sharedTransitionTag={uniqueTag}
      />                  
    </View>
  );
}

Animate from

export default function TransactionScreen() {
	
  ...
    
  const uniqueTag =
    transaction.uuid + sender.clerkId + transaction.type;

  return (
    <Animated.Image
      source={{ uri: sender?.imageUrl }}
      style={styles.avatar}  
      sharedTransitionTag={senderTransitionTag}
    />          
  );
}

Animate to

Data Fetching Web

export default function UsersPage({ params }: UserPageProps) {
  const { data, isLoading, error } = api.admin.getUser.useQuery({
    clerkId: params.id,
  });

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error!</div>;
  }

  return <User user={data} />;
}

Client Side Fetching

export default async function UsersPage({ params }: UserPageProps) {
  const user = await serverAPIClient().admin.getUser({ 
    clerkId: params.id 
  });

  return <User user={data} />;
}

Server Components

What about Error and Loading state?

import { auth } from "@clerk/nextjs";
import { appRouter } from "@the-bank/api";

export function serverAPIClient() {
  const authSession = auth();

  return appRouter.createCaller({
    auth: authSession,
  });
}
// error.ts
export default function UserErrorPage() {
  return (    
    <h1>User Not Found!</h1>
  );
}

// loading.ts
import { Skeleton } from "~/components/ui/skeleton";

export default function UsersPage() {
  return (
    <Skeleton className="mx-auto mt-3 h-[14px] w-full rounded-full" />   
  );
}

Error and Loading Components

Animations

import { View } from 'react-native';

export default function App() {
  return (
    <View
      style={{
        width: 100,
        height: 100,
        backgroundColor: 'green',
      }}
    />
  );
}

Regular View

import Animated from 'react-native-reanimated';

export default function App() {
  return (
    <Animated.View
      style={{
        width: 100,
        height: 100,
        backgroundColor: 'green',
      }}
    />
  );
}

Animated View

import Animated, { useSharedValue } from 'react-native-reanimated';

export default function App() {
  const width = useSharedValue(100);

  return (
    <Animated.View
      style={{
        width,
        height: 100,
        backgroundColor: 'green',
      }}
    />
  );
}

Shared Value

import Animated, { useSharedValue } from "react-native-reanimated";

export default function App() {
  const width = useSharedValue(100);

  return (
    <View>
      <Animated.View
        style={{
          width,
          height: 100,
          backgroundColor: "green",
        }}
      />
      <Button
        onPress={() => {
          width.value = width.value + 50;
        }}
        title="Click me"
      />
    </View>
  );
}

Mutate shared value

import Animated, { useSharedValue, withSpring } from 'react-native-reanimated';

export default function App() {
  const width = useSharedValue(100);

  return (
    <View>
      <Animated.View
        style={{
          width,
          height: 100,
          backgroundColor: "green",
        }}
      />
      <Button
        onPress={() => {
          width.value = withSpring(width.value + 50);
        }}
        title="Click me"
      />
    </View>
  );
}

Mutated with animation

https://docs.swmansion.com/react-native-reanimated/

Real Time

using Pusher

Trigger event

  addFounds: protectedProcedure
    .input(z.object({ value: z.number().min(1) }))
    .mutation(async ({ ctx, input }) => {
      try {
        await addFounds(ctx.auth.userId, input.value);

        await PusherServer.trigger(
          channels.mainChannel.name,
          channels.mainChannel.events.addFounds,
          {
            value: input.value,
            clerkUserId: ctx.auth.userId,
          },
        );
      } catch (error) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message:
            error instanceof Error
              ? error.message
              : "Error creating transaction!",
        });
      }
    }),

Handle event

export const PusherClient = new Pusher(env.NEXT_PUBLIC_PUSHER_KEY, {
  cluster: "eu",
});

export function usePusherUpdates() {
  const apiUtils = api.useContext();

  const { toast } = useToast();

  useEffect(() => {
    async function handler(data: AddFoundEvent) {
      toast({
        title: "A new transaction has been made",
        description: `Someone add ${formatCurrencyValue(
          data.value,
        )} to their account`,
      });

      await apiUtils.admin.invalidate();
    }

    const updateChannel = PusherClient.subscribe(channels.mainChannel.name);
    updateChannel.bind(channels.mainChannel.events.addFounds, handler);

    return () => {
      PusherClient.unsubscribe(channels.mainChannel.name);
      updateChannel.unbind(channels.mainChannel.events.addFounds, handler);
    };
  }, [toast, apiUtils.admin]);

Handle event

export const PusherMobile = Pusher.getInstance();

if (!process.env.EXPO_PUBLIC_PUSHER_KEY) {
  throw new Error("Missing PUSKER_KEY env variable");
}

PusherMobile.init({
  apiKey: process.env.EXPO_PUBLIC_PUSHER_KEY,
  cluster: "eu",
});

export function usePusherUpdates() {
  const { user } = useAuth();
  const apiUtils = api.useContext();

  useEffect(() => {
    async function events() {
      try {
        await PusherMobile.connect();

        await PusherMobile.subscribe({
          channelName: channels.mainChannel.name,
          onEvent: (event: PusherEvent) => {
            if (event.eventName === channels.mainChannel.events.addFounds) {
              const data = JSON.parse(event.data as string);
              const parsedData = AddFoundEventSchema.parse(data);

              // Handle event
            }

            if (
              event.eventName === channels.mainChannel.events.newTransaction
            ) {
              const data = JSON.parse(event.data as string);
              const parsedData = NewTransactionEventSchema.parse(data);

              // Handle event
            }
          },
        });
      } catch (error) {
        console.log(error);
      }
    }

    void events();

    return () => {
      void PusherMobile.unsubscribe({ channelName: channels.mainChannel.name });
      void PusherMobile.disconnect();
    };
  }, [apiUtils.user.balance, user?.id]);
}

Monitoring

Sentry & Axiom

Full Stack Workshop

By czystyl

Full Stack Workshop

  • 119