Front-end Engineering Lab

Flux Architecture (Redux Patterns)

Implement unidirectional data flow with Redux and modern patterns

Flux is a unidirectional data flow pattern that makes state predictable and debuggable. Redux is the most popular implementation, powering apps at Facebook, Uber, and Twitter.

Core Principles

1. Single source of truth (store)
2. State is read-only (immutable)
3. Changes via pure functions (reducers)
4. Unidirectional flow: Action → Reducer → Store → View

Modern Redux Toolkit Setup

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['user/setUser'],
      },
    }),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Creating Slices (Redux Toolkit)

// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      // Redux Toolkit uses Immer, so you can "mutate"
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    reset: (state) => {
      state.value = 0;
    },
  },
});

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
export default counterSlice.reducer;

Async Actions (Thunks)

// store/slices/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserState {
  data: User | null;
  loading: boolean;
  error: string | null;
}

const initialState: UserState = {
  data: null,
  loading: false,
  error: null,
};

// Async thunk
export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string) => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearUser: (state) => {
      state.data = null;
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Failed to fetch user';
      });
  },
});

export const { clearUser } = userSlice.actions;
export default userSlice.reducer;

Typed Hooks

// hooks/redux.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Using in Components

// components/Counter.tsx
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { increment, decrement, incrementByAmount } from '@/store/slices/counterSlice';

export function Counter() {
  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

Selectors with Reselect

// store/selectors/userSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../index';

// Basic selector
export const selectUser = (state: RootState) => state.user.data;

// Memoized selector (prevents unnecessary re-renders)
export const selectUserName = createSelector(
  [selectUser],
  (user) => user?.name ?? 'Guest'
);

// Complex selector with multiple inputs
export const selectUserStats = createSelector(
  [
    (state: RootState) => state.user.data,
    (state: RootState) => state.posts.items,
  ],
  (user, posts) => {
    if (!user) return null;
    
    return {
      postsCount: posts.filter(p => p.authorId === user.id).length,
      joinedDate: user.createdAt,
    };
  }
);

// Parameterized selector
export const makeSelectPostsByAuthor = () =>
  createSelector(
    [
      (state: RootState) => state.posts.items,
      (_state: RootState, authorId: string) => authorId,
    ],
    (posts, authorId) => posts.filter(p => p.authorId === authorId)
  );

Middleware

// middleware/logger.ts
import { Middleware } from '@reduxjs/toolkit';

export const loggerMiddleware: Middleware = (store) => (next) => (action) => {
  console.log('Dispatching:', action);
  console.log('Current state:', store.getState());
  
  const result = next(action);
  
  console.log('Next state:', store.getState());
  
  return result;
};

// Add to store
export const store = configureStore({
  reducer: { ... },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loggerMiddleware),
});

Normalized State

// store/slices/postsSlice.ts
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';

interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
}

// Entity adapter for normalized state
const postsAdapter = createEntityAdapter<Post>({
  selectId: (post) => post.id,
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
});

const postsSlice = createSlice({
  name: 'posts',
  initialState: postsAdapter.getInitialState({
    loading: false,
  }),
  reducers: {
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
    postsReceived: postsAdapter.setAll,
  },
});

// Generated selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById,
  selectIds: selectPostIds,
} = postsAdapter.getSelectors((state: RootState) => state.posts);

RTK Query (Data Fetching)

// services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface Post {
  id: string;
  title: string;
  content: string;
}

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Posts'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Posts'],
    }),
    getPost: builder.query<Post, string>({
      query: (id) => `/posts/${id}`,
      providesTags: (_result, _error, id) => [{ type: 'Posts', id }],
    }),
    createPost: builder.mutation<Post, Partial<Post>>({
      query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post,
      }),
      invalidatesTags: ['Posts'],
    }),
    updatePost: builder.mutation<Post, { id: string; updates: Partial<Post> }>({
      query: ({ id, updates }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: updates,
      }),
      invalidatesTags: (_result, _error, { id }) => [{ type: 'Posts', id }],
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
} = api;

Using RTK Query

// components/PostsList.tsx
import { useGetPostsQuery, useCreatePostMutation } from '@/services/api';

export function PostsList() {
  const { data: posts, isLoading, error } = useGetPostsQuery();
  const [createPost, { isLoading: isCreating }] = useCreatePostMutation();

  const handleCreate = async () => {
    try {
      await createPost({
        title: 'New Post',
        content: 'Content here',
      }).unwrap();
      
      console.log('Post created!');
    } catch (error) {
      console.error('Failed to create post:', error);
    }
  };

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

  return (
    <div>
      <button onClick={handleCreate} disabled={isCreating}>
        Create Post
      </button>
      
      <ul>
        {posts?.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Redux DevTools

// Automatic with Redux Toolkit
// Open Redux DevTools in browser

// Time-travel debugging
// Action history
// State diff
// Export/import state

Testing

// store/slices/__tests__/counterSlice.test.ts
import reducer, { increment, decrement } from '../counterSlice';

describe('counter slice', () => {
  const initialState = { value: 0, status: 'idle' };

  it('should handle increment', () => {
    const actual = reducer(initialState, increment());
    expect(actual.value).toBe(1);
  });

  it('should handle decrement', () => {
    const actual = reducer({ value: 5, status: 'idle' }, decrement());
    expect(actual.value).toBe(4);
  });
});

Best Practices

  1. Use Redux Toolkit (not plain Redux)
  2. Normalize state for relational data
  3. Co-locate slices by feature
  4. Use selectors with memoization
  5. Type everything with TypeScript
  6. RTK Query for server state
  7. DevTools for debugging
  8. Test reducers independently
  9. Keep actions small and focused
  10. Document state shape

When to Use Redux

Use Redux when:

  • Large app (10+ components need shared state)
  • Complex state logic
  • Need time-travel debugging
  • Team familiar with Redux
  • Need middleware (logging, analytics)

Don't use Redux when:

  • Small app (use Context)
  • Mostly server state (use React Query)
  • Simple state (use useState)
  • Performance-critical (use Jotai/Signals)

Common Pitfalls

Mutating state directly: Breaks immutability
Use Redux Toolkit (Immer handles it)

Too much in Redux: UI state in global store
Only truly global state

No selectors: Re-renders everywhere
Use memoized selectors

Async in reducers: Side effects break purity
Use thunks or RTK Query

Deeply nested state: Hard to update
Normalize with entity adapters

Redux provides predictable state management with excellent tooling—use Redux Toolkit for a modern, ergonomic experience!

On this page